Skip to content

feat(cli)!: return the canonical diff document from --format json (#693) - #702

Draft
willhea wants to merge 6 commits into
developfrom
worktree-issue693-cli-canonical-json
Draft

feat(cli)!: return the canonical diff document from --format json (#693)#702
willhea wants to merge 6 commits into
developfrom
worktree-issue693-cli-canonical-json

Conversation

@willhea

@willhea willhea commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Closes #693. Part of #691.

What changed

./diff_bill.py compare --format json returned the engine's internal diff dictionary
while POST /api/compare?output=json returned the canonical contract. Both now route
through compare/xml.py and return the same document. ./diff_pdf.py gains the same
--format flag, which it had no JSON output to conflict with.

compare/xml.py gains compare_xml_trees, the JSON sibling of the existing
compare_xml_trees_html. The internal shape stays reachable as a library call
(bill_diff_to_dict is unchanged and keeps its pipeline caller), so no --format internal escape hatch was needed to preserve it.

Breaking, on purpose. A caller parsing --format json gets a different document,
and --include-unchanged is now a usage error rather than a flag. Consumers were
checked in the issue: nothing in this repository read the shape except
scripts/compare_differs.py, which is updated here, and BillTrax vendors the engine
and imports it as a library rather than running the command.

Flags

Flag Before After
--include-unchanged Set include_unchanged on both formats Removed
--financial Filtered on both formats, and also enriched on --format json Filters only
--filter Help named match_path, an internal field Help describes what it matches
-o/--output "Output JSON file", though the default has been html since --format was added "Output file"

--include-unchanged goes because the canonical document cannot represent an unchanged
node: xml_diff_to_canonical drops every unchanged entry. Re-measured here on
tests/corpus/118-hr-8752, driving the library path the flag fed: it moved
summary.unchanged from 0 to 286 while the document's changes array stayed at 39
entries either way. The count referred to 286 entries the document does not contain.

--financial had two meanings chosen by a sibling flag (#694): --format json passed
financial=args.financial, so the flag 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, so one meaning is left.

--filter is worth a second look. It matches the normalized path the engine pairs
nodes on, which is not the path the document publishes: that one keeps the original
casing 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 gap
itself is #689's shape and is left alone here.

Verification

tests/test_cli_api_parity.py is finding 9 of the parity audit, which found that
nothing 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 byte
identity; 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:

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

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.md specifies a
document, 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:

  • Reverting the routing in cmd_compare turns all three XML legs red (parity, the
    filename-scope test, and schema validation). Re-run after the assertion above was
    removed, to confirm it had contributed nothing to the negative control.
  • Dropping the label derivation from render_pdf_diff_json turns the PDF parity leg
    red.
  • Cutting filter_text= and financial_only= out of cmd_compare turns all six
    flag-plumbing gates red, across test_diff_bill.py and test_financial_diff.py.

The gate's input is copied under stems carrying no <n>_ ordinal, because the two
surfaces derive version identity from a filename by different algorithms
(label_from_stem strips the prefix and reads the ordinal;
web/app.py::_label_from_filename does neither). That divergence is #692, not this
issue, and a second test holds it to the versions key alone on the committed corpus
stems, 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. The
new module is named in ci.yml's remaining-slow step, which
test_every_slow_module_is_run_by_a_workflow requires and which caught its absence.

scripts/render_examples.py regenerates the four committed example reports
byte-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:

  1. The two pipelines do not agree on the summary's key set at all. On the same fixture
    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; the
    PDF side emits a Counter over the kinds actually present, so it omits zeros and has
    no unchanged kind to emit. So this is a pipeline-neutrality break in a contract
    whose whole point is pipeline neutrality, not only an undocumented key.
  2. After this change, unchanged is provably constant at 0 on every document any
    shipped entry point produces, since nothing can set include_unchanged through
    compare/xml.py any 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.py stays, per #698, which is sequenced after
#653.

Note for review

include_unchanged was also removed from compare_xml_trees_html and
_build_from_trees, not only from the CLI. The CLI was its only caller, and what it did
there was produce a document whose summary.unchanged disagreed with its own changes
array. filter_diff keeps its parameter: the internal shape is allowed to carry
unchanged nodes, and the canonical adapter is the boundary where they stop.

🤖 Generated with Claude Code

willhea and others added 4 commits August 24, 2026 18:43
`./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>
…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>
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.

The command line returns the engine's internal JSON instead of the published canonical contract

1 participant