feat(cli)!: return the canonical diff document from --format json (#693) - #702
Draft
willhea wants to merge 6 commits into
Draft
feat(cli)!: return the canonical diff document from --format json (#693)#702willhea wants to merge 6 commits into
willhea wants to merge 6 commits into
Conversation
`./diff_bill.py compare --format json` served the engine's internal diff dictionary while `POST /api/compare?output=json` served the canonical contract. The two agreed on the comparison and shared two of eight top-level keys, so a tool written against one could not read the other, and the only documented route to the contract was to render an HTML report and click its download button, which a script cannot do. The dictionary was never a second output format. It is a pipeline stage, and only on the XML branch: `bill_diff_to_dict` feeds `xml_diff_to_canonical`, and the PDF branch skips it entirely, which is why `./diff_pdf.py` had no JSON output to diverge. The canonical formatter was layered on top of what already existed in d7c4280 rather than replacing it, the HTML path was migrated onto it, and the JSON path was left pointing at the original shape. Both formats now enter `compare/xml.py`, which gains `compare_xml_trees` as the JSON sibling of `compare_xml_trees_html`. The internal shape stays reachable as a library call, `bill_diff_to_dict` is unchanged and still has its pipeline caller, so no escape-hatch flag is needed to keep it available. Two flags collapse with the branch: - `--include-unchanged` is removed. `xml_diff_to_canonical` drops every `unchanged` entry, so the canonical document cannot represent an unchanged node. Measured on `tests/corpus/118-hr-8752`, its only remaining effect on `--format html` was to move the embedded document's `summary.unchanged` from 0 to 286, a count of entries the document does not contain. Passing it is now a usage error rather than a silent no-op. - `--financial` becomes filter-only. It used to mean two things chosen by a sibling flag: `--format json` passed `financial=args.financial`, so it both filtered and enriched, while `--format html` filtered on the flag with enrichment hardcoded on. Enrichment is unconditional in `compare/xml.py`, and the canonical document has carried no money on a change since #671. All four `compare` help strings are corrected in the same pass. `-o/--output` said "Output JSON file" though `--format` has defaulted to html since it was added; `--filter` named `match_path`, an internal field, and now describes what it matches behaviourally, since the normalized path it filters on is not the `path` the document publishes; `--financial` loses its enrichment clause; `--include-unchanged` is gone. Verification. `tests/test_cli_api_parity.py` is finding 9 of the parity audit: it runs one fixture pair through the command and through the endpoint and requires byte-identical JSON, then validates the command's output against `schema/canonical-diff.schema.json`. Both directions were proven to fire, reverting the routing turns all three red. The pair is copied under stems carrying no `<n>_` ordinal so the two filename-to-label algorithms agree; that divergence is #692, and a second test holds it to `versions` alone on the committed corpus stems. Refs #691, #692, #694 Closes #693 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PDF command had no JSON output of any kind, which was a consequence of pipeline shape rather than a decision: the XML branch built an intermediate diff dictionary on the way to canonical and the command line serialized it, while the PDF branch goes from `PdfDiff` straight to canonical and had nothing lying around to serialize. With `diff_bill.py compare --format json` now returning the contract instead of that intermediate, the same flag can mean the same thing on both commands. `render_pdf_diff_json` delegates to `compare.pdf.compare_pdfs`, the entry point the web app already calls, so the two surfaces cannot drift. `-o/--output` and the parser description drop their HTML-only wording. `scripts/compare_differs.py` moves off the command line and onto the library. It reads paired old/new amounts, which the canonical document deliberately does not carry (#671 removed `amount_entries`; #115 holds the account-level model that would say what a figure is), so the shape it needs is `bill_diff_to_dict`. Calling that directly is the intended way to reach it. Its `.get("match_path", [])` becomes a subscript in the same pass: the default would have printed an empty breadcrumb on every row rather than failing, which reads as a bill with unnamed accounts instead of as a broken script. The parity gate is parametrized over both formats. The PDF half is the one with no prior behaviour to preserve, so pinning it now is what keeps it from acquiring a second vocabulary the way the XML half did; dropping the label derivation from `render_pdf_diff_json` was run once to confirm the PDF leg goes red. Refs #693 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README told anyone building against the output that `--format json` emits the
engine's internal shape and that the way to get the published contract is to render an
HTML report, open it and click a download button. That is now the wrong instruction in
both halves: the flag returns the contract, and no browser is involved.
`docs/architecture.md` recorded the divergence as deliberate ("one path deliberately
does not go through them"). It now records what the shape actually is, a pipeline stage
on the XML branch only, and why nothing was kept to select it.
Also drops the `--include-unchanged` example, adds the canonical JSON to the two command
table rows, and names `./diff_pdf.py --format json`.
Refs #693
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`TestCliFinancial` drives `diff_bill.py compare --format json` as a real subprocess and
asserted the enrichment the flag used to add: a `financial_summary` and a per-change
`financial` block. Neither reaches the canonical document, so the two tests become one
statement of what the flag does now (filters) and one of what it no longer does (adds
money). Cutting `financial_only=` out of `cmd_compare` was run once to confirm both go
red, along with the four flag-plumbing gates in `test_diff_bill.py`.
`TestCli::test_filter_flag` now tolerates `path: {v1: null, v2: null}`, which front
matter carries on both sides; without the guard a filter failure surfaces as a
TypeError from `" ".join(None)` rather than as the assertion.
`test_engine_installs.py` gains a note rather than an assertion. Its docstring recorded
that a wheel missing `compare/` still passed this test because `--format json` never
entered that subpackage, which stopped being true in the same change. The content
assertions stay: they are what makes the test say something about the diff rather than
about the import.
Refs #693
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 tasks
…ntity
The parity test asserted `cli_text == json.dumps(response.json(), indent=2)` and called
that byte identity. It was not: the endpoint's response was parsed and then re-serialized
with the command's own formatter, so the only thing the second assertion could catch
beyond the first was a difference in key order. Changing the endpoint's separators or its
Unicode escaping would not have failed it.
The two surfaces already serialize differently, measured on
`tests/corpus/118-hr-8752`:
CLI 551,433 bytes b'{\\n "schema_version": "3.0",\\n "generator": {\\n "name": "d'
API 380,599 bytes b'{"schema_version":"3.0","generator":{"name":"deltatrack","ve'
raw bytes equal: False parsed documents equal: True
`json.dumps(..., indent=2)` indents and escapes non-ASCII; Starlette's `JSONResponse`
emits compact UTF-8. So the file carries `\\u2014` where the response carries raw em dash
bytes.
Byte identity is not the invariant worth holding. Getting it would mean indenting the
HTTP response to match a file on disk, about 45% more payload for every API caller, to
prove something `schema/canonical-diff.md` does not ask for: it specifies a document, and
#691 asks the surfaces for the same answer rather than the same whitespace. The second
assertion is removed, the invariant is named "the same canonical document", and the
module docstring records the measurement so the next reader does not re-add a byte
comparison that would fail on formatting.
The negative control was re-run after removing the assertion: reverting the routing in
`cmd_compare` still turns all three XML legs red, so the assertion contributed nothing to
it.
`_cli_json` now reads with an explicit `encoding="utf-8"`, matching the output-encoding
work on develop (#627). A host whose preferred encoding is not UTF-8 would otherwise
misread the em dashes in a bill's section headings and fail this comparison for a reason
that has nothing to do with parity.
Refs #693, #691
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the branch current with develop so the pull request is mergeable again. It was reported CONFLICTING rather than merely behind, and a merge queue cannot resolve a content conflict, so the conflicting line had to be settled on the branch. One conflict, in `diff_pdf.py::main`. Develop added an explicit output encoding (#627, UTF-8 report output on a non-UTF-8 host) to the same statement this branch rewrote when it gave the command a `--format` flag: develop: args.output.write_text(html, encoding="utf-8") branch: args.output.write_text(output) Resolved by taking both: `args.output.write_text(output, encoding="utf-8")`. Dropping the encoding would have reintroduced the defect #627 fixed, silently, on the code path this branch touches. `cmd_compare`'s equivalent write in `diff_bill.py` merged cleanly and keeps its `encoding="utf-8"`. Merged rather than rebased, per CONTRIBUTING's merge-queue section: branches here do not rebase on develop, because the queue tests the merge commit that is actually about to land. That also keeps the four already-published commits intact rather than rewriting history that has already been through CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #693. Part of #691.
What changed
./diff_bill.py compare --format jsonreturned the engine's internal diff dictionarywhile
POST /api/compare?output=jsonreturned the canonical contract. Both now routethrough
compare/xml.pyand return the same document../diff_pdf.pygains the same--formatflag, which it had no JSON output to conflict with.compare/xml.pygainscompare_xml_trees, the JSON sibling of the existingcompare_xml_trees_html. The internal shape stays reachable as a library call(
bill_diff_to_dictis unchanged and keeps its pipeline caller), so no--format internalescape hatch was needed to preserve it.Breaking, on purpose. A caller parsing
--format jsongets a different document,and
--include-unchangedis now a usage error rather than a flag. Consumers werechecked in the issue: nothing in this repository read the shape except
scripts/compare_differs.py, which is updated here, and BillTrax vendors the engineand imports it as a library rather than running the command.
Flags
--include-unchangedinclude_unchangedon both formats--financial--format json--filtermatch_path, an internal field-o/--output--formatwas added--include-unchangedgoes because the canonical document cannot represent an unchangednode:
xml_diff_to_canonicaldrops everyunchangedentry. Re-measured here ontests/corpus/118-hr-8752, driving the library path the flag fed: it movedsummary.unchangedfrom 0 to 286 while the document'schangesarray stayed at 39entries either way. The count referred to 286 entries the document does not contain.
--financialhad two meanings chosen by a sibling flag (#694):--format jsonpassedfinancial=args.financial, so the flag filtered and enriched, while--format htmlfiltered on the flag with enrichment hardcoded on. Enrichment is unconditional in
compare/xml.py, and the canonical document has carried no money on a change since#671, so one meaning is left.
--filteris worth a second look. It matches the normalized path the engine pairsnodes on, which is not the
paththe document publishes: that one keeps the originalcasing and leads with the division (
["TITLE I—DEPARTMENT OF DEFENSE", "Military construction, army"]against["department of defense", "military construction, army"]). The help now describes the behaviour instead of naming either field. The gapitself is #689's shape and is left alone here.
Verification
tests/test_cli_api_parity.pyis finding 9 of the parity audit, which found thatnothing ran one input through both surfaces and compared. It runs one committed fixture
pair through the command and through the endpoint and requires the same canonical
document, in both XML and PDF, then validates the command's output against
schema/canonical-diff.schema.json.The comparison is of the parsed documents, not of the bytes. An earlier revision of this
branch asserted
cli_text == json.dumps(response.json(), indent=2)and called that byteidentity; it was not, because it re-serialized the parsed response with the command's own
formatter, so the only thing it could catch beyond document equality was a difference in
key order. The two surfaces genuinely serialize differently, measured on
tests/corpus/118-hr-8752:Byte identity is also not the invariant worth holding. Getting it would mean indenting
the HTTP response to match a file on disk, roughly 45% more payload for every API caller,
to prove something the contract does not ask for:
schema/canonical-diff.mdspecifies adocument, and #691 asks the surfaces for the same answer rather than the same whitespace.
The module docstring records that measurement so the next reader does not re-add a byte
comparison that would fail on formatting while saying nothing about agreement.
Both directions were proven rather than assumed:
cmd_compareturns all three XML legs red (parity, thefilename-scope test, and schema validation). Re-run after the assertion above was
removed, to confirm it had contributed nothing to the negative control.
render_pdf_diff_jsonturns the PDF parity legred.
filter_text=andfinancial_only=out ofcmd_compareturns all sixflag-plumbing gates red, across
test_diff_bill.pyandtest_financial_diff.py.The gate's input is copied under stems carrying no
<n>_ordinal, because the twosurfaces derive version identity from a filename by different algorithms
(
label_from_stemstrips the prefix and reads the ordinal;web/app.py::_label_from_filenamedoes neither). That divergence is #692, not thisissue, and a second test holds it to the
versionskey alone on the committed corpusstems, so the exclusion is one named key wide rather than an unbounded escape.
Gates run: full suite green (
uv run pytest),ruff check,ruff format --check. Thenew module is named in ci.yml's
remaining-slowstep, whichtest_every_slow_module_is_run_by_a_workflowrequires and which caught its absence.scripts/render_examples.pyregenerates the four committed example reportsbyte-identically, confirming the HTML path did not move.
Not done, and why
summary.unchanged. The issue raised it as a smaller question worth answering here.It is left alone, because it is outside the scope this branch was given and because
changing what every document emits is a contract decision with its own baselines to
update. Two measurements are worth having before that decision is made, and this change
sharpens both:
pair the XML document carries
{"added": 18, "removed": 2, "modified": 18, "unchanged": 0, "moved": 1}and the PDF document carries{"modified": 18, "removed": 2, "moved": 1, "added": 16}. The XML side always emits five keys; thePDF side emits a
Counterover the kinds actually present, so it omits zeros and hasno
unchangedkind to emit. So this is a pipeline-neutrality break in a contractwhose whole point is pipeline neutrality, not only an undocumented key.
unchangedis provably constant at 0 on every document anyshipped entry point produces, since nothing can set
include_unchangedthroughcompare/xml.pyany more. That makes "stop emitting it" strictly stronger than"document it", which was not true when the issue posed the either/or.
Filed as #706 (the diff summary reports different change categories for the same bill
depending on whether it was compared from XML or PDF), with both measurements and the
three options costed, rather than decided in passing here.
The intermediate dict in
compare/xml.pystays, per #698, which is sequenced after#653.
Note for review
include_unchangedwas also removed fromcompare_xml_trees_htmland_build_from_trees, not only from the CLI. The CLI was its only caller, and what it didthere was produce a document whose
summary.unchangeddisagreed with its ownchangesarray.
filter_diffkeeps its parameter: the internal shape is allowed to carryunchanged nodes, and the canonical adapter is the boundary where they stop.
🤖 Generated with Claude Code