docs(adr): propose observation identity for parsed nodes (ADR 0019) - #561
Conversation
…be one Draft ADR 0019. An artifact that records a judgment about a parsed node has no way to say which parse it meant, so a parser change silently redefines its subject. That is what happened to tests/data/similarity_labels.json: three of twelve pairs no longer resolve to a node, and its regeneration script exits on ImportError from the #492 rename, while its own test stays green because it scores the stored string. The decision keeps three questions apart: observation identity (source digest, parser revision, node address), content integrity (text_sha256, which two nodes may share), and cross-version identity (an output, never a key). scripts/probe_observation_identity.py is the evidence, so the numbers in the record are re-derivable rather than transcribed. On the committed corpus, 58 documents and 49,747 nodes: 385 body texts occur more than once across 23 documents; match_path is duplicated in 32 documents; element_id is unique and non-empty everywhere, which is the property the record turns into an invariant. Amends ADR 0009 rather than replacing it. Nothing in the engine changes and the canonical contract is untouched. Refs #175 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt id
Review asked the address choice to be falsified rather than defended. It does
not survive.
The key is already scoped by (source_sha256, parser_revision), so a source
change or a parser change produces a different observation identity by
construction. The address therefore only has to designate one node within one
source under one parser revision. Under those semantics element_id was buying a
stability the contract does not need, at the cost of making an empirical
property of GPO's markup load-bearing.
Four findings decided it. element_id uniqueness held on all 129 documents across
two corpus roots and could not be broken, but it is sampled rather than
constructed, and bill_tree already reads it as attrib.get("id", ""). Source
traceability, the one requirement that might have favoured it, is partial: 144
ids across 48 of 58 documents are parser-synthesized front-matter and appear
nowhere in the source bytes. Its stability across a body-extraction change is a
hazard rather than a feature, since that is precisely the change that drifted
the answer key. And a PDF block has no id, so the contract would have been
XML-only by construction.
The ordinal's precondition is emission determinism, which is measured rather
than assumed: identical node sequences across repeated parses and across
PYTHONHASHSEED values, on 129 documents. The digest is proven able to fire by
swapping two adjacent nodes, where a set-based digest stays green.
element_id is still recorded beside the ordinal for traceability and debugging.
Nothing depends on it, so a bill with absent or repeated ids degrades tracing
rather than breaking identity. Two new invariants pay for the change: emission
determinism, and that an ordinal indexes the complete emitted sequence rather
than a filtered view.
Also narrows the regeneration claim, which overreached: the existing twelve
records cannot gain provenance never stored, and three still need human
adjudication. And states parser_revision as an architectural requirement with
the AST content hash as an accepted implementation, rather than freezing that
algorithm in the decision.
Refs #175
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Address choice falsified: REJECTED. The record now uses the node ordinal.Pushed as The requirement, stated exactlyBecause the key is already scoped by source digest and parser revision, a source change or
Nothing more. The element id was buying stability across parser changes, which this What the element id satisfies that an ordinal does notOne thing, and it turned out to be partial. Reconstructability from the source without Measured, that property holds for 49,603 of 49,747 nodes. 144 ids, across 48 of the 58 Nothing else survived:
Why the last row is a hazard rather than a featureAn element id survives a change to how a node's body is extracted, because it is read A key that still resolves across that change invites auto-migrating a judgment onto a node EvidenceEverything is in uv run python scripts/probe_observation_identity.py tests/corpus
Invariants that changedRemoved. There is no longer any invariant requiring the element id to be unique or Added, and these are the price of the change:
The first asserts a promise ADR 0008 already makes, rather than adding one. Two wording corrections, both taken"The answer key becomes regenerable" overreached and now reads: future and migrated The parser revision is no longer over-specified. The architectural requirement is now:
Derived rather than declared is the load-bearing word. The AST content hash is recorded One thing the change opens rather than closesThe ordinal generalizes to the PDF pipeline where the element id could not, and that was Also worth flaggingProduction already relies on the element id elsewhere. Gates, re-run on
|
| gate | result |
|---|---|
ruff check . |
pass |
ruff format --check . |
pass, 197 files |
pytest -m "not slow and not browser" |
1666 passed, 5 skipped, 15 xfailed |
pytest -m browser |
not run (no Playwright browser here; no web surface touched) |
pytest -m slow --deselect tests/test_govinfo_corpus_parity.py |
exit 0, 1249 passed, 27 skipped, no skip ceiling exceeded |
Updated the status of the decision from 'Proposed' to 'Accepted'.
…probe The record read as a combined research report, review log and implementation spec. An ADR should answer what we rely on and why, not how we discovered it. 338 lines to 216, with the decision, its scope and every conclusion unchanged. Removed the corroborating measurements from the wider corpora, which restated a point the committed-corpus figures already carry; the four-part narration of the element_id falsification, kept only as the one sentence needed to see why the ordinal is the dependency; the chronology of how the wrong choice was found; the AST hashing mechanism beyond noting it as an accepted implementation; the research programme's own past mistakes; and the fault-injection matrix, which becomes seven invariants plus the two notes that are not obvious. Kept the element_id span index in text_serializer, because a reader who is told element_id is not load-bearing will otherwise assume it is unused. Kept the 0009 relationship, compressed to why it is an amendment. The one Context claim with no in-repo reproducer was "three of twelve observations no longer resolve" -- its evidence lived only in the unmerged research branch. Reproduced it independently here, same three ids, and added the check to the existing probe rather than a new file, so every remaining figure in the record comes from one command. tests/test_fixture_layout.py then caught the probe respelling tests/data/ as a CWD-relative path, which resolves only from the repository root. Now takes DATA_DIR from corpus_paths like its siblings. Refs #175 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the status flip in adbb0f4. docs/decisions/README.md makes accepted records append-only -- "once a record is accepted, its substance is not edited" -- and the record is still an unmerged draft under active editing, so Accepted would forbid the trim that followed it. The maintainer sets it to Accepted on approval, per the same file's proposal process. The decision itself is unchanged and remains agreed in principle; this is the status field describing where the record sits in review, not a reopening. Refs #175 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Updated the status of the decision from 'Proposed' to 'Accepted'.
Related issue
No closing keyword. This proposes a decision rather than completing a tracked task, and
there is no issue whose definition of done it satisfies.
Refs #175 (the epic whose matching work depends on it), refs #203 (Study 2, whose labeled
dataset is the artifact this record is about), refs #486 (which asks for the mechanism
this record licenses).
What does this change?
Several artifacts in this repository record a human or measured judgment about a parsed
node of a bill: the hand-labeled matching answer key, the PDF anchor goldens, the
extraction goldens. Each one has to name the node it is talking about, and today the
answer key names it by its text.
That does not work, and this pull request adds the measurement showing why.
Appropriations bills are assembled from repeated boilerplate, so the same body text
appears at several different places in one document. On the committed corpus (58 XML
documents, 49,747 nodes):
The direction of that failure is what makes it serious. Matching a stored text to "the
node it came from" succeeds against the wrong twin, so a lookup that should miss instead
appears to hit. Every collapse makes the thing being measured look better than it is.
This has already happened.
tests/data/similarity_labels.jsonstores its texts verbatimand carries no record of which document, which parser or which node it meant. Three of
its twelve pairs no longer correspond to any node the parser emits, and the script that
would rebuild it cannot run at all. Its own test stays green throughout, because it
scores the stored string against the stored score, which is true by construction.
Nothing here means the legislation changed or that the human judgments were wrong. The
source XML is byte-identical to what entered the repository. What moved is how the parser
divides that XML into nodes, which re-segments the unit the judgment was about. The
fixture had no way to say which parse it meant.
The decision is to identify a parsed observation by
(source digest, parser revision, node address), and to keep three questions permanently apart:Three details worth a reviewer's attention:
already scoped by source digest and parser revision, a change to either produces a
different observation identity by construction, so the address only has to designate one
node within one source under one parser revision. An earlier draft used the XML element
id; review asked for that to be falsified rather than defended, and it did not survive.
The element id's uniqueness is an empirical property of the published markup that we can
only sample, while a list index is unique by construction. The element id is still
recorded beside the ordinal, for tracing an observation back to the source document, but
correctness no longer rests on it. The full comparison is in the discussion below.
an intention rather than a fact, and a field that names its own correctness while being
checked by nobody is decorative. A git commit is also rejected: it moves when
documentation changes, and it does not move for an uncommitted edit to the parser,
which is the direction that matters. The record states this as a requirement and leaves
the hashing mechanism as an implementation detail.
element id is a cross-version match key, and the record says so explicitly. Treating a
bill's own numbering as a stable identity is the mistake the matching research exists to
avoid.
Files
docs/decisions/0019-observation-identity.md— the proposed record. Context, decision,rejected alternatives, consequences, what stays undecided, its relationship to ADR 0009,
and the invariants and tests it implies.
scripts/probe_observation_identity.py— the evidence. Read-only, defaults to thecommitted corpus so it needs no downloads, and reports all three candidate address
fields rather than only the one the record adopts.
AGENTS.md,docs/decisions/README.mdandscripts/README.md.How to test
The record's numbers are reproducible rather than transcribed:
That prints the table above plus the three supporting results the record relies on:
grouping key rather than an identity;
different
PYTHONHASHSEEDand the sequence digest must not move. It does not, acrossthree seeds and two corpus roots;
ids are actually present in the source bytes. The other 144 are synthesized by the
parser, which is why "traceable back to the document" is recorded as a partial property
rather than an absolute one.
Three claims were confirmed by running them rather than by reading:
scripts/build_similarity_labels.py, which rebuilds the answer key, exits onImportError: cannot import name '_MOVE_THRESHOLD' from 'deltatrack.diff_bill'. Thenames moved in an earlier refactor and the script did not move with them.
digest, while a digest over the node set stays green. A determinism result that could
never go red would be indistinguishable from a check that reads nothing.
ordinal is not that the element id fails; it is that its uniqueness is contingent where
a list index's is not.
Checklist
Closes #...) — n/a, deliberately. This proposes adecision; no tracked issue's definition of done is met by merging it.
Refslines above.not run; it needs a Playwright browser download this environment does not have, and the
change touches no web surface. Results below.
touched, the new script is standalone and imported by nothing, and the canonical diff
contract is untouched. The record's invariants describe tests to be written when it is
implemented, and it names one that will start red.
Gate results
ruff check .ruff format --check .pytest -m "not slow and not browser"pytest -m browserpytest -m slow --deselect tests/test_govinfo_corpus_parity.pyKnown limitations
The record is deliberately narrow, and names four things it does not settle:
and adding it needs a consumer first.
to PDF where an element id could not, and that is one reason it was chosen. But
determinism has been measured on the XML side only, and the record does not assume the
PDF result. The same test is required before any artifact addresses a PDF block this way.
Rewriting a fixture that encodes human rulings is a maintainer decision, and three of its
records need legislative adjudication regardless.
re-review, or hard failure.
One dependency is worth stating plainly: implementing this record needs the answer-key
rebuild script repaired, and that repair currently exists only on the provision-matching
research branch.
AI assistance
Drafted with Claude Code. The measurements were produced by running the committed probe,
not quoted from an earlier document, and the failure claims above were confirmed by
executing them. The framing, the scope boundaries and the decision to keep this record
separate from the matching-architecture one are mine, as was the challenge that overturned
the original address choice (see the discussion below).