Skip to content

Commit 9d8c7b4

Browse files
committed
fix(parser): make the leaf-section cap reduce any tree shape
The cap only merged ADJACENT leaf siblings, but the real 10-K explosion is hundreds of SINGLE-LEAF PARENTS (heading -> one body leaf) that have no adjacent leaf-sibling pairs at all — so the cap silently did nothing and a 92-page filing sailed past the 400 cap at 463-1465 leaves, each costing a summarize + HyDE + multi-axis LLM call (the throughput killer for full ingest). Fix it in two phases: 1. collapseSingleLeafParents flattens every heading -> lone-leaf chain (bottom-up, so deep chains fold in one pass) so the formerly only-children become adjacent leaf siblings. Count is unchanged; the parent absorbs the child's content + page range and becomes the leaf. 2. The existing smallest-first adjacent-pair merge then reduces the count to the cap, with a defensive single-leaf-parent collapse for any pair a table leaf blocked. The top-level sections are wrapped under a synthetic root so the merge step — which shrinks a sibling list by rewriting parent.Children — can shrink the TOP-level list too (a bare slice parameter would not propagate the shrink back). This is what makes the invariant hold for a flat list of single-leaf parents. Invariant: for any tree with > N mergeable leaves, capLeafSections drives the leaf count to <= N. Content is always preserved (concatenated, page ranges unioned) and table sections (Metadata["table"]=="true") are never merged or collapsed. Nothing is disabled — the full section tree is still produced; it's just bounded. Tests: 1000 single-leaf-parents -> <= 400 with no content loss (the case the old cap failed and that let 1465 through), deep heading->subheading-> body chains, a mixed flat/single-leaf/multi-child tree, and a table-protection test asserting table leaves survive verbatim.
1 parent 0cda733 commit 9d8c7b4

2 files changed

Lines changed: 307 additions & 17 deletions

File tree

pkg/parser/cap_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,29 @@ func leafTree(n, size int) []Section {
1515
return []Section{{Level: 1, Title: "parent", Children: kids}}
1616
}
1717

18+
// singleLeafParentTree builds n top-level "heading" sections, each an
19+
// internal node with EXACTLY ONE leaf child carrying `size` chars. This
20+
// is the real 10-K explosion shape: hundreds of heading -> one-body-leaf
21+
// parents with NO adjacent leaf-sibling pairs anywhere. The pre-fix cap
22+
// (adjacent-siblings only) could not reduce this tree at all.
23+
func singleLeafParentTree(n, size int) []Section {
24+
parents := make([]Section, n)
25+
for i := range parents {
26+
parents[i] = Section{
27+
Level: 1,
28+
Title: "heading",
29+
Children: []Section{{
30+
Level: 2,
31+
Title: "body",
32+
Content: strings.Repeat("y", size),
33+
PageStart: i + 1,
34+
PageEnd: i + 1,
35+
}},
36+
}
37+
}
38+
return parents
39+
}
40+
1841
func TestCapLeafSections_MergesDownToCap(t *testing.T) {
1942
tree := leafTree(1000, 50)
2043
if got := countLeafSections(tree); got != 1000 {
@@ -81,6 +104,148 @@ func TestCapLeafSections_MergesSmallestFirst(t *testing.T) {
81104
}
82105
}
83106

107+
// TestCapLeafSections_SingleLeafParentsReduce is the regression test for
108+
// the bug that let a 92-page 10-K through at 463-1465 leaves: a tree made
109+
// entirely of single-leaf parents (heading -> one body leaf) has no
110+
// adjacent leaf-sibling pairs, so the old adjacent-only merge did nothing.
111+
// The fix must collapse those chains and merge down to the cap, losing no
112+
// content.
113+
func TestCapLeafSections_SingleLeafParentsReduce(t *testing.T) {
114+
tree := singleLeafParentTree(1000, 30)
115+
if got := countLeafSections(tree); got != 1000 {
116+
t.Fatalf("setup: countLeafSections = %d, want 1000", got)
117+
}
118+
orig := totalContentLen(tree) // 1000 * 30
119+
120+
capped := capLeafSections(tree, 400)
121+
122+
if got := countLeafSections(capped); got > 400 {
123+
t.Errorf("after cap: %d leaves, want <= 400 (the bug let 1465 through)", got)
124+
}
125+
// No content lost. Collapses/merges insert at most a "\n\n" (2 chars)
126+
// per fold; with < 1000 folds total the upper bound is generous.
127+
if got := totalContentLen(capped); got < orig || got > orig+2*1000 {
128+
t.Errorf("content not preserved: got %d chars, want in [%d, %d]", got, orig, orig+2*1000)
129+
}
130+
}
131+
132+
// TestCapLeafSections_DeepSingleLeafChains exercises heading -> subheading
133+
// -> body chains (depth-3 single-leaf parents). The bottom-up collapse
134+
// must fold the whole chain so the bodies become mergeable siblings.
135+
func TestCapLeafSections_DeepSingleLeafChains(t *testing.T) {
136+
const n = 600
137+
roots := make([]Section, n)
138+
for i := range roots {
139+
roots[i] = Section{Level: 1, Title: "h1", Children: []Section{{
140+
Level: 2, Title: "h2", Children: []Section{{
141+
Level: 3, Title: "body", Content: strings.Repeat("z", 20),
142+
PageStart: i + 1, PageEnd: i + 1,
143+
}},
144+
}}}
145+
}
146+
if got := countLeafSections(roots); got != n {
147+
t.Fatalf("setup: countLeafSections = %d, want %d", got, n)
148+
}
149+
orig := totalContentLen(roots)
150+
151+
capped := capLeafSections(roots, 400)
152+
if got := countLeafSections(capped); got > 400 {
153+
t.Errorf("after cap: %d leaves, want <= 400", got)
154+
}
155+
if got := totalContentLen(capped); got < orig {
156+
t.Errorf("content shrank: got %d chars, want >= %d", got, orig)
157+
}
158+
}
159+
160+
// TestCapLeafSections_MixedShapeReducesToCap throws a tree that mixes a
161+
// flat sibling list, single-leaf parents, and a multi-child branch at the
162+
// cap. The invariant is unconditional: > N mergeable leaves must come
163+
// down to <= N regardless of shape.
164+
func TestCapLeafSections_MixedShapeReducesToCap(t *testing.T) {
165+
tree := []Section{
166+
{Level: 1, Title: "flat", Children: func() []Section {
167+
out := make([]Section, 300)
168+
for i := range out {
169+
out[i] = Section{Level: 2, Title: "f", Content: "ff", PageStart: i + 1, PageEnd: i + 1}
170+
}
171+
return out
172+
}()},
173+
}
174+
// Append 300 single-leaf parents as additional top-level nodes.
175+
tree = append(tree, singleLeafParentTree(300, 10)...)
176+
177+
if got := countLeafSections(tree); got != 600 {
178+
t.Fatalf("setup: countLeafSections = %d, want 600", got)
179+
}
180+
capped := capLeafSections(tree, 100)
181+
if got := countLeafSections(capped); got > 100 {
182+
t.Errorf("after cap: %d leaves, want <= 100", got)
183+
}
184+
}
185+
186+
// TestCapLeafSections_NeverMergesTableSections asserts that table leaves
187+
// (Metadata["table"]="true") are preserved verbatim and never merged or
188+
// collapsed, even under a cap small enough that everything else must
189+
// merge around them.
190+
func TestCapLeafSections_NeverMergesTableSections(t *testing.T) {
191+
mkTable := func(page int, content string) Section {
192+
return Section{
193+
Level: 1, Title: "Table", Content: content,
194+
PageStart: page, PageEnd: page,
195+
Metadata: map[string]string{"table": "true"},
196+
}
197+
}
198+
// Three tables interleaved with prose leaves under one parent, plus a
199+
// pile of single-leaf-parent prose so we're well over the cap.
200+
kids := []Section{
201+
{Level: 2, Title: "p1", Content: "prose-a"},
202+
mkTable(1, "| A | B |\n| --- | --- |\n| 1 | 2 |"),
203+
{Level: 2, Title: "p2", Content: "prose-b"},
204+
mkTable(2, "| C | D |\n| --- | --- |\n| 3 | 4 |"),
205+
{Level: 2, Title: "p3", Content: "prose-c"},
206+
mkTable(3, "| E | F |\n| --- | --- |\n| 5 | 6 |"),
207+
}
208+
tree := []Section{{Level: 1, Title: "parent", Children: kids}}
209+
tree = append(tree, singleLeafParentTree(200, 10)...)
210+
211+
capped := capLeafSections(tree, 5)
212+
213+
// Every original table's content must still be present, verbatim, in
214+
// some leaf.
215+
tableContents := []string{
216+
"| A | B |\n| --- | --- |\n| 1 | 2 |",
217+
"| C | D |\n| --- | --- |\n| 3 | 4 |",
218+
"| E | F |\n| --- | --- |\n| 5 | 6 |",
219+
}
220+
var leaves []Section
221+
var collect func([]Section)
222+
collect = func(ss []Section) {
223+
for i := range ss {
224+
if len(ss[i].Children) == 0 {
225+
leaves = append(leaves, ss[i])
226+
} else {
227+
collect(ss[i].Children)
228+
}
229+
}
230+
}
231+
collect(capped)
232+
233+
for _, want := range tableContents {
234+
found := false
235+
for _, l := range leaves {
236+
// A table must survive as its OWN leaf — its content present and
237+
// the table metadata intact (i.e. it wasn't merged into prose).
238+
if l.Content == want && l.Metadata["table"] == "true" {
239+
found = true
240+
break
241+
}
242+
}
243+
if !found {
244+
t.Errorf("table content %q was merged away or lost its metadata", want)
245+
}
246+
}
247+
}
248+
84249
func totalContentLen(sections []Section) int {
85250
n := 0
86251
for i := range sections {

pkg/parser/pdf.go

Lines changed: 142 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -605,34 +605,158 @@ func countLeafSections(sections []Section) int {
605605
return n
606606
}
607607

608-
// capLeafSections enforces a ceiling on the total leaf-section count.
609-
// While the count exceeds maxLeaves it repeatedly merges the two
610-
// smallest ADJACENT leaf siblings under whichever parent currently has
611-
// the most leaf children — so the runaway byte-split sections collapse
612-
// back first, while genuinely distinct top-level sections are left
613-
// alone. maxLeaves <= 0 disables the cap.
608+
// capLeafSections enforces a ceiling on the total leaf-section count so a
609+
// pathological PDF can't shatter into thousands of tiny leaves (each of
610+
// which costs a summarize + HyDE + multi-axis LLM call at ingest, which
611+
// is what throttles/stalls the full pipeline). maxLeaves <= 0 disables
612+
// the cap; a tree already at or under the cap is returned untouched.
614613
//
615-
// Merged leaves concatenate their content (blank-line separated), keep
616-
// the first sibling's title, and union their page ranges. Table
617-
// sections are attached AFTER this pass (attachTableSections), so their
618-
// numeric content is never merged away.
614+
// The reduction is robust to ANY tree shape, which is the fix for the
615+
// real 10-K explosion: that document is not a flat list of adjacent
616+
// leaves but hundreds of SINGLE-LEAF PARENTS (heading -> one body leaf),
617+
// which have no adjacent leaf-sibling pairs at all — so the old
618+
// adjacent-only merge silently did nothing and a 92-page filing sailed
619+
// past the cap at 463-1465 leaves. We fix it in two phases:
620+
//
621+
// 1. collapseSingleLeafParents flattens every heading -> lone-leaf chain
622+
// so the formerly-only-children become adjacent leaf SIBLINGS. This
623+
// restructures the tree without changing the leaf count (the parent
624+
// absorbs the child and itself becomes the leaf).
625+
//
626+
// 2. The merge loop then repeatedly merges the smallest adjacent leaf
627+
// pair until the count is back under the cap (a defensive collapse
628+
// step covers any pair a table leaf blocked).
629+
//
630+
// The invariant: for any tree with > maxLeaves MERGEABLE leaves,
631+
// capLeafSections drives countLeafSections to <= maxLeaves. The only
632+
// leaves it will not merge are table sections (Metadata["table"]=="true")
633+
// — those carry distinct numeric content and must survive verbatim. In
634+
// the normal parse flow table sections are attached AFTER this pass
635+
// (attachTableSections), so the cap never sees them; the guard is
636+
// defensive for any caller that pre-attaches tables.
637+
//
638+
// Merged/collapsed leaves concatenate their content (blank-line
639+
// separated), keep the parent/first-sibling title, and union their page
640+
// ranges, so no body text is ever dropped.
619641
func capLeafSections(sections []Section, maxLeaves int) []Section {
620642
if maxLeaves <= 0 {
621643
return sections
622644
}
623-
// Guard against pathological loops: at most one merge per excess leaf.
624-
for guard := 0; countLeafSections(sections) > maxLeaves && guard < 100000; guard++ {
625-
if !mergeOneSmallestAdjacentLeafPair(sections) {
626-
break // no mergeable adjacent leaf pair anywhere
645+
if countLeafSections(sections) <= maxLeaves {
646+
return sections // already under the cap — leave structure untouched
647+
}
648+
649+
// Wrap the top-level sections under a synthetic root. The merge step
650+
// shrinks a sibling list in place (it rewrites parent.Children), which
651+
// only propagates back to the caller when the mutated list is a struct
652+
// FIELD, not a bare slice parameter. The runaway shape we're fixing —
653+
// hundreds of single-leaf PARENTS — needs the TOP-level list to shrink,
654+
// so we make the top level a nested list (root.Children) and operate on
655+
// that; the wrapper itself (length 1) never changes. We return
656+
// root.Children at the end.
657+
root := []Section{{Children: sections}}
658+
659+
// Phase 1: flatten single-leaf-parent chains so only-children become
660+
// mergeable siblings. Count is unchanged; structure is normalised.
661+
root[0].Children = collapseSingleLeafParents(root[0].Children)
662+
663+
// Phase 2: merge adjacent leaf pairs (smallest first) until under cap.
664+
// Each merge removes one leaf and each fallback collapse removes one
665+
// internal node — both strictly monotonic — so the loop is bounded;
666+
// the guard is belt-and-braces against a logic regression.
667+
for guard := 0; countLeafSections(root) > maxLeaves && guard < 1000000; guard++ {
668+
if mergeOneSmallestAdjacentLeafPair(root) {
669+
continue
670+
}
671+
// No adjacent mergeable pair left. Try to free one up by
672+
// collapsing a remaining single-leaf parent; if that's impossible
673+
// too, every remaining leaf is unmergeable (e.g. table sections)
674+
// and we stop — there is nothing left we're permitted to merge.
675+
if !collapseOneSingleLeafParent(&root) {
676+
break
677+
}
678+
}
679+
return root[0].Children
680+
}
681+
682+
// isTableLeaf reports whether s is a table section that must never be
683+
// merged or collapsed (its cell content is distinct numeric data).
684+
func isTableLeaf(s *Section) bool {
685+
return s.Metadata != nil && s.Metadata["table"] == "true"
686+
}
687+
688+
// collapseSingleLeafParents recursively flattens every parent that has
689+
// exactly one child which is a (non-table) leaf: the parent absorbs the
690+
// child's content + page range and itself becomes the leaf. Chains
691+
// (heading -> subheading -> body) collapse fully in one bottom-up pass.
692+
//
693+
// Leaf count is preserved (one internal node + one leaf become one leaf);
694+
// the point is purely to turn only-children into adjacent siblings so the
695+
// adjacent-pair merge can then reduce the count. Returns the rewritten
696+
// slice.
697+
func collapseSingleLeafParents(sections []Section) []Section {
698+
for i := range sections {
699+
s := &sections[i]
700+
if len(s.Children) == 0 {
701+
continue
702+
}
703+
// Bottom-up: collapse within the children first so a chain folds
704+
// from the leaf upward.
705+
s.Children = collapseSingleLeafParents(s.Children)
706+
if len(s.Children) == 1 && len(s.Children[0].Children) == 0 && !isTableLeaf(&s.Children[0]) {
707+
absorbChildIntoParent(s, s.Children[0])
708+
s.Children = nil
627709
}
628710
}
629711
return sections
630712
}
631713

714+
// collapseOneSingleLeafParent finds the first parent in the tree with
715+
// exactly one (non-table) leaf child and collapses it (parent absorbs the
716+
// leaf, becomes a leaf). Returns false when no such parent exists. Used
717+
// as a defensive fallback inside the merge loop when an adjacent pair was
718+
// blocked by a table leaf; the bulk flattening is done up front by
719+
// collapseSingleLeafParents.
720+
func collapseOneSingleLeafParent(sections *[]Section) bool {
721+
s := *sections
722+
for i := range s {
723+
n := &s[i]
724+
if len(n.Children) == 1 && len(n.Children[0].Children) == 0 && !isTableLeaf(&n.Children[0]) {
725+
absorbChildIntoParent(n, n.Children[0])
726+
n.Children = nil
727+
return true
728+
}
729+
if len(n.Children) > 0 {
730+
if collapseOneSingleLeafParent(&n.Children) {
731+
return true
732+
}
733+
}
734+
}
735+
return false
736+
}
737+
738+
// absorbChildIntoParent folds a leaf child's content and page range up
739+
// into its parent. The parent keeps its own title (the heading) and
740+
// gains the child's body; an empty parent body is replaced outright so we
741+
// don't prefix a stray separator. Page ranges union (min start, max end).
742+
func absorbChildIntoParent(parent *Section, child Section) {
743+
switch {
744+
case strings.TrimSpace(parent.Content) == "":
745+
parent.Content = child.Content
746+
case strings.TrimSpace(child.Content) != "":
747+
parent.Content = parent.Content + "\n\n" + child.Content
748+
}
749+
parent.PageStart = minNonZero(parent.PageStart, child.PageStart)
750+
if child.PageEnd > parent.PageEnd {
751+
parent.PageEnd = child.PageEnd
752+
}
753+
}
754+
632755
// mergeOneSmallestAdjacentLeafPair finds the adjacent leaf-sibling pair
633756
// with the smallest combined content length anywhere in the tree and
634-
// merges it in place. Returns false when no sibling list has two
635-
// adjacent leaves to merge.
757+
// merges it in place. Only pairs where BOTH siblings are NON-table leaves
758+
// are eligible — table sections are never merged. Returns false when no
759+
// sibling list has two adjacent mergeable leaves.
636760
func mergeOneSmallestAdjacentLeafPair(sections []Section) bool {
637761
bestList := (*[]Section)(nil)
638762
bestIdx := -1
@@ -642,7 +766,8 @@ func mergeOneSmallestAdjacentLeafPair(sections []Section) bool {
642766
walk = func(list *[]Section) {
643767
s := *list
644768
for i := 0; i+1 < len(s); i++ {
645-
if len(s[i].Children) == 0 && len(s[i+1].Children) == 0 {
769+
if len(s[i].Children) == 0 && len(s[i+1].Children) == 0 &&
770+
!isTableLeaf(&s[i]) && !isTableLeaf(&s[i+1]) {
646771
size := len(s[i].Content) + len(s[i+1].Content)
647772
if bestSize < 0 || size < bestSize {
648773
bestSize, bestList, bestIdx = size, list, i

0 commit comments

Comments
 (0)