A parsed document hierarchy, measured and not wired in

A negative result. Every figure on this page is re-derived from the committed bytes of data/real by one command, and a test fails if this page and that command disagree.

.venv/bin/python scripts/measure_structure_signal.py

THE FINDING. The tree was built to improve alignment confidence. It was wired into app/diff/engine.py and app/pipeline.py, measured on the eight real version pairs, and taken back out. No confidence anywhere in the product moves because of it.

The two branches it was built for fired 0 and 0 times on real filings. Every confidence that moved โ€” 112 of 421 โ€” moved on a third branch nobody had argued for: the parser failing to read both documents, and that failure being used to withhold the restructure cap. A parse refusal lifting a score from 0.5 to a median of 0.9872 is absence licensing certainty, which is the one thing this repository forbids by name.

What was kept: the parser, this measurement, and the refusals. 27 of 102 real filings yield a hierarchy; the rest are refused with a stated reason. That number is worth having and it is not worth pretending it moved a confidence score.

Why this was measured at all

app/diff/engine.py::_alignment_confidence decides how much to trust that two passages are the same passage. Its only structural evidence is the integer at the head of each one, and its own docstring has always said that is a proxy. This page is what happened when the proxy was measured rather than argued about.

What was measuredNumber
.txt files in data/real, across eight jurisdiction prefixes (ga in ky mo nc oh ut va)102
Passages in data/real under app.ingestion.ingest._segment107,122
Passages where _section_label fires42,546
That, as a percentage of all passages39.7
Distinct labels it returns2,077
Modified changes across the eight real version pairs421
Of those, capped only because two leading integers disagreed on a line-numbered page112

Among the labels _section_label hands back are 2024, 2025, 0.0000, 207, 41 and 480. Some are years, one is a table cell and the rest are line numbers off a deposition page. Where two such numbers disagree, the engine caps alignment confidence at RESTRUCTURE_CONFIDENCE_CEILING and the change escalates. That is the right behaviour when the numbers are section numbers. It is a queue full of alarms when they are not. That problem is real and this page does not solve it. The last section says what would.

Part A — what the parser can read

27 of 102 real filings yield a hierarchy. The rest are refused, each with a reason naming the evidence. All three synthetic proceeding versions parse.

Outcome over data/realDocuments
parsed27
candidates found, none forming a sibling sequence44
the leading integers are a line-number gutter19
no heading candidate anywhere in the document8
a coherent scheme, but too few headings to be a hierarchy4

A refusal rate of three in four is not a defect being tuned away. Most of this corpus genuinely has no hierarchy a machine can see: a two-page errata cover letter, a semi-annual load report that is one table, a motion whose only structure is paragraph breaks. Inventing a tree for those would be the confident wrong answer ADR-003 forbids. An unparseable document yields no tree, never a flat one pretending to be a tree — and a DocumentTree with zero roots raises rather than constructs, so there is no fifth outcome for a future refactor to slip into.

The document the nesting rules were learned on

in-45911-final-order.txt is the best real nesting case in the corpus: I.A.1.1.1, four levels, with the label and its title on separate lines throughout because the text came out of a PDF. It is also 382,479 characters that _segment cuts into 238 passages, the longest of them 17,433 lines. A parser that treated one passage as one node could not see this document at all, which is why app/diff/structure.py parses lines inside passages and carries absolute offsets.

What each rule bought, measured by deleting it

Each figure below comes from taking that one rule out of app/diff/structure.py's own source, importing the result as a second module, and re-parsing all 102 filings. Not from a hand-written copy of the rule with the condition flipped, and not from memory. Four of this page's earlier figures came from memory and none of the four reproduced.

RuleFilings that change shape without itWhat the worst one does
A candidate must have a title12in-46258-aes-direct-testimony-spanos goes from 856 candidates to 11,954, and from a refusal (3 accepted headings, under MIN_HEADINGS) to a tree of 102 headings, every one a table row.
A bare integer scheme needs a parent177 filings the parser refuses today grow an outline made of nothing but bare integers. in-45911-final-order goes from 22 arabic headings to 29 and its footnotes take the top level away from its real I.
A bare integer needs a terminatorCandidates in in-45911-final-order go from 1,212 to 217. This one changes no document's outcome on its own; it is what makes the rules after it affordable.
Two heading-shaped lines in a row are a column4in-46258-final-order goes from 98 headings to 99. Small numbers, invisible to a parse rate, and each one a fictitious section a reader would be asked to trust.
A context, once left, never reopens3The same depreciation exhibit goes from 0 dotted headings to 49, every one of them a depreciation rate read as a subsection number.
A dot-leader line is a contents entry2oh-24-508-EL-ATA-opinion-and-order goes from 22 headings to 19, and ga-56002-hotaling has the depth of every heading in it changed.
A title must contain a real word1oh-24-508-EL-ATA-mckenzie goes from 28 headings to 25, and its scheme order flips, because the fictitious rows land in different places from the real headings.
A roman numeral must round-trip0It rejects 233 labels over 14 spellings — LLC, LL, ILL, CIVIC — and changes no document, because the sibling rules would have dropped them anyway. Kept because relying on that is relying on a coincidence.

The gutter detector, and the one thing that makes it believable

A document is called line-numbered when a set fraction of its non-blank lines open with a small integer followed by text on the same line, and somewhere in it that many such lines run consecutively both in position and in value. The two constants are GUTTER_LINE_FRACTION and GUTTER_MIN_ADJACENT_RUN.

With exactly those two values, 19 filings are flagged, and every one of them is direct or rebuttal testimony. That is the check that makes the thresholds credible — not the numbers themselves, but the fact that the set they select has a name. A test pins it: if a tuning change ever flags an order, a tariff or a settlement, the constants have stopped meaning what this page says they mean.

The detector errs where erring costs least. There are 33 filings with "testimony" in the name and it misses 14 of them, whose gutters did not survive PDF extraction as leading integers. It has never flagged a non-testimony document on this corpus.

Two details in the rule, one bought with a document and one that has never decided anything:

One line, one heading

I is the ninth letter and it is also the numeral one, and no regex settles which. Both readings could be accepted at once, and in in-46258-final-order both were: the offset 102,139 entered the tree twice, at depth 0 as ('I',) and at depth 2 as ('H','2','I'), and the deeper of the two came out with char_end == char_start — a node of zero width that path_at can never return. The document reported 113 headings and offered one fewer, and the higher number is what an earlier version of this page published.

The rule now is that the reading with the longer sibling run wins: at that offset the alpha run is 9 and the roman run 3, so the letter wins, which is right — the line is titled COSS/Rate Design and its neighbours are H. and J. Acceptance is then re-run without the losing reading rather than the loser being deleted after the fact, because that I was also the value-1 that opened the roman sequence: delete it and roman is left holding II and III, a run of two, and the scheme goes with them — 110 headings and no roman at all. Re-running lets roman open instead at the I. at offset 130,193. The document reads 98 headings today.

There is no gold set here. What can be checked is that no node is unreachable and no offset is claimed twice, and a test checks both across all 27 parsed filings.

Part B — what wiring the signal in would have changed

These 8 are the pairs in data/real that really are two versions of one document. scripts/ingest_dockets.py sets out in its own comments why two other look-alikes are not pairs, and it is right. For each pair the shipped confidence is compared against the confidence the proposed rule would have returned. The proposed rule now lives in the measuring script under the name _withdrawn_confidence, which is the only honest place for it: a branch kept alive in app/ so a measurement can reach it is a branch that gets re-wired by accident.

Pair (before → after)Parse outcomemodifiedconfidences the rule would move
ga-44280 rules-regs tariff, clean → trackedno coherent scheme, both sides660
ky-2025-00113 kollen, direct → correctedgutter, both sides1340
ky-2025-00113 perry, direct → erratagutter, both sides20
mo-ET-2025-0184 michels, direct → substitutegutter, both sides30
mo-ET-2025-0184 non-unanimous stipulation, original → correctedtree, both sides30
nc-e100-sub208a semi-annual report → updateno coherent scheme, both sides60
ut-24-035-04 ellis phase 3, direct → erratagutter, both sides370
ut-24-035-04 laconte phase 3, direct → amendedgutter, both sides170112
Totals421112
Movement, by branchCountWhat the branch claims
LOWERED_BY_TREE0The hierarchy contradicts two agreeing labels, so cap. This is the only branch that makes an alignment be better rather than look better.
RAISED_BY_SAME_NODE0The hierarchy confirms the two passages are one node, so do not cap.
RAISED_BY_GUTTER112Both documents were refused as line-numbered, so no leading integer here was ever a section number.
UNCHANGED309Nothing moved.

Why that is a reason not to ship it

The two authorised branches fired zero times. The one that fired was not authorised. It raises a confidence score on the strength of a parse failure — from 0.5 to a median of 0.9872 and a maximum of 0.9895. "I could not read either of these documents" is not evidence that two paragraphs are the same paragraph. It is the shape of the mistake this whole product exists to refuse.

And it went wrong the first way anyone looked. The branch was written as "either side is a gutter refusal". Pair Missouri's non-unanimous stipulation, which parses to a tree and numbers its own paragraphs, with Utah's Ellis testimony, which is a gutter refusal, and the rule stripped the cap off the stipulation's paragraphs 21. and 22. — two different paragraphs setting the same credit condition for two customer classes, 0.9877 similar — and reported them as one paragraph edited. Every scrap of evidence for that came from the other document in the pair.

Narrowing it to "both sides" fixes the crossed pair and leaves the principle where it was. A refusal on both sides is still a refusal. What the narrowing did show is that the refusal branch was the entire feature.

All the movement is in one document pair, and one pair is not a corpus. The other seven produced nothing at all: six because the parser refused both sides and there was nothing to say, and the seventh — Missouri's stipulation, the one pair where both sides yield a tree — because the hierarchy left all 3 of its changes exactly where the proxy had them.

The alarms are real. This was the wrong instrument for them.

112 escalations produced by a shifted gutter number are 112 alarms an analyst opened and dismissed. 2 of them are word-for-word identical passages three words long, where the gutter number is a large fraction of the characters:

ut-24-035-04-laconte  BEFORE '249          Account.'   AFTER '250          Account.'
ut-24-035-04-laconte  BEFORE '292         made:'       AFTER '293         made:'

That is worth fixing. The fix is not a parsed hierarchy standing behind the proxy and overruling it on the strength of its own failures. It is _section_label learning that a page with a line-number gutter has no section numbers on it — a change to the proxy, on the proxy's own terms, with none of this machinery in the confidence path. The gutter detector in app/diff/structure.py is the part of this work that would be reused, and it is measured above. That change is not made here: it moves 112 real confidences and it needs its own decision record, its own tests and its own review, not a paragraph at the end of a page about something else.

Part C — the renumbering case

I did not find a real renumbering in the eight version pairs. The search parsed both sides of every pair and looked for a heading whose title survives while its path changes. It found nothing. That is not a claim that none exists — the corpus was not read by eye, and a title match cannot see a heading that also had its wording edited. It is a claim about what this search found, which is nothing.

So the demonstration below is SYNTHETIC, built from data/v2_revised_proposed_rule.txt and data/v3_final_order.txt, and it is labelled synthetic here, in the script's output, and in the commit that added it.

SYNTHETIC before: 'SECTION 6. COLLATERAL AND FINANCIAL ASSURANCE'
SYNTHETIC after : '5.4 Collateral and Financial Assurance.'
SYNTHETIC v2 path at the heading: ('6',)
SYNTHETIC v3 path at the heading: ('5', '5.4')
SYNTHETIC withdrawn verdict: different-node
SYNTHETIC confidence, shipped: 0.5000
SYNTHETIC confidence, withdrawn rule: 0.5000

Both the shipped proxy and the withdrawn rule cap this one. That is the point, and it is also why the tree was never load-bearing here: the proxy was already doing it. A tree that rescued this change — that decided SECTION 6 and 5.4 were one node because their titles match — would be a tree that had learned to agree with the mistake ADR-004 exists to prevent.

Limits

This measures eight version pairs in eight jurisdictions. It does not tell you what the parser does on a document type not in this corpus.