Skip to content

Commit c011453

Browse files
committed
tree: reconcile section tree + LLM TOC into a canonical heading-path map
Ingestion builds two independent structures — the parser's Section tree (content, summaries; the IDs citations resolve to) and the LLM-built TOC tree (the logical outline with clean headings + page anchors). They are never reconciled, so the map a citation resolves against and the map that holds the real headings can diverge. BuildHeadingPaths closes that gap without merging the trees: for every section it returns the canonical heading path it belongs under, matched by page-range containment (deepest containing TOC node wins; best overlap when a section straddles a boundary). Sections with no page range, and every section when the TOC is empty, are absent so callers fall back to existing behaviour. This is the reconciliation map HAL-70 needs to emit a real structural title_path on citations.
1 parent 2051693 commit c011453

2 files changed

Lines changed: 427 additions & 0 deletions

File tree

pkg/tree/heading_path.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package tree
2+
3+
import "strings"
4+
5+
// BuildHeadingPaths reconciles the parser's section tree with the
6+
// LLM-built TOC tree into one canonical lookup: section ID → logical
7+
// heading path (the chain of TOC titles from the document root down to
8+
// the most specific TOC node that covers the section's pages).
9+
//
10+
// This is the bridge HAL-109 introduces. Ingestion builds two
11+
// independent structures:
12+
//
13+
// - the parser's Section tree, which carries content, summaries, and
14+
// candidate questions, and whose IDs every citation resolves to; its
15+
// Title fields are whatever the parser recovered (often empty or a
16+
// non-semantic chunk label for content leaves), and
17+
// - the LLM-built TOC tree ([]TOCNode, persisted on documents.toc_tree),
18+
// which carries the document's logical outline with the clean heading
19+
// vocabulary clients actually expect ("Item 8" → "Balance Sheet") and
20+
// page anchors, but no content.
21+
//
22+
// Because the two are never reconciled, the map a citation resolves
23+
// against (parser titles) and the map that holds the real headings (the
24+
// TOC) can — and do — diverge. BuildHeadingPaths closes that gap without
25+
// merging the two trees: it returns, for every section, the canonical
26+
// heading path it belongs under, so a citation can carry a real
27+
// structural path instead of a parser chunk label.
28+
//
29+
// # Matching: page-range containment
30+
//
31+
// A section belongs under the TOC node whose effective page span best
32+
// covers the section's own [PageStart, PageEnd]. Among the TOC nodes that
33+
// overlap a section, the winner is chosen by, in order:
34+
//
35+
// 1. containment — a node that fully contains the section beats one that
36+
// merely overlaps it (the section sits cleanly inside that heading);
37+
// 2. depth — the deeper (more specific) heading wins, so a section under
38+
// "Item 8 → Balance Sheet" maps to both, ending at "Balance Sheet"
39+
// rather than stopping at "Item 8";
40+
// 3. overlap — more shared pages wins;
41+
// 4. span — the tighter node wins (more specific);
42+
// 5. start page — earlier wins, purely to make the result deterministic.
43+
//
44+
// # Degradation
45+
//
46+
// Sections with no page range (PageStart/PageEnd <= 0 — the normal state
47+
// for non-paginated formats), and every section when the TOC is empty or
48+
// nil, are simply absent from the returned map. Callers treat a missing
49+
// entry as "no canonical heading path known" and fall back to existing
50+
// behaviour, so wiring this in never makes a citation worse than today.
51+
//
52+
// The returned map is keyed by SectionID and never nil (an empty map is
53+
// returned when nothing could be mapped) so callers can index it without
54+
// a nil check.
55+
func BuildHeadingPaths(root *Section, toc []TOCNode) map[SectionID][]string {
56+
out := make(map[SectionID][]string)
57+
if root == nil || len(toc) == 0 {
58+
return out
59+
}
60+
61+
maxPage := documentMaxPage(root, toc)
62+
entries := flattenTOC(toc, nil, maxPage)
63+
if len(entries) == 0 {
64+
return out
65+
}
66+
67+
root.Walk(func(s *Section) bool {
68+
if s == nil || s.PageStart <= 0 || s.PageEnd <= 0 {
69+
return true
70+
}
71+
if path, ok := bestHeadingPath(entries, s.PageStart, s.PageEnd); ok {
72+
out[s.ID] = path
73+
}
74+
return true
75+
})
76+
return out
77+
}
78+
79+
// tocEntry is a flattened TOC node with its effective (resolved) page
80+
// span and the full heading path leading to it. depth is the node's
81+
// 0-indexed nesting level, used to prefer more specific headings.
82+
type tocEntry struct {
83+
start int
84+
end int
85+
depth int
86+
path []string
87+
}
88+
89+
// span is the inclusive page count the entry covers. A zero/negative
90+
// span (malformed node that survived resolution) sorts last.
91+
func (e tocEntry) span() int {
92+
if e.end < e.start {
93+
return 1 << 30
94+
}
95+
return e.end - e.start + 1
96+
}
97+
98+
// flattenTOC walks the TOC forest depth-first, resolving each node's
99+
// effective end page and accumulating the heading path. parentPath is
100+
// the chain of titles above this level; parentEnd bounds open-ended
101+
// nodes (a node whose EndPage is 0 runs until the next sibling's start,
102+
// or — for the last sibling — its parent's end, or the document end).
103+
//
104+
// Empty titles are skipped in the accumulated path so a structural
105+
// wrapper node with no heading doesn't inject a blank segment, but its
106+
// children still inherit the correct ancestry.
107+
func flattenTOC(nodes []TOCNode, parentPath []string, parentEnd int) []tocEntry {
108+
return flattenTOCAt(nodes, parentPath, parentEnd, 0)
109+
}
110+
111+
func flattenTOCAt(nodes []TOCNode, parentPath []string, parentEnd, depth int) []tocEntry {
112+
var out []tocEntry
113+
for i, n := range nodes {
114+
start := n.StartPage
115+
end := resolveEndPage(nodes, i, parentEnd)
116+
117+
path := parentPath
118+
if t := normaliseTitle(n.Title); t != "" {
119+
// Copy so sibling branches never share/alias the backing array.
120+
path = append(append([]string(nil), parentPath...), t)
121+
}
122+
123+
if start > 0 && end >= start {
124+
out = append(out, tocEntry{start: start, end: end, depth: depth, path: path})
125+
}
126+
if len(n.Nodes) > 0 {
127+
// A child can't extend past its parent's end; pass end down so
128+
// an open-ended deepest child is bounded by its ancestor.
129+
childBound := end
130+
if childBound <= 0 {
131+
childBound = parentEnd
132+
}
133+
out = append(out, flattenTOCAt(n.Nodes, path, childBound, depth+1)...)
134+
}
135+
}
136+
return out
137+
}
138+
139+
// resolveEndPage computes the effective inclusive end page for nodes[i].
140+
// An explicit EndPage wins. Otherwise the node runs until the page
141+
// before the next sibling that carries a StartPage; if there is no such
142+
// sibling it inherits parentEnd (the enclosing node's end, or the
143+
// document's last page at the top level).
144+
func resolveEndPage(nodes []TOCNode, i, parentEnd int) int {
145+
if nodes[i].EndPage > 0 {
146+
return nodes[i].EndPage
147+
}
148+
for j := i + 1; j < len(nodes); j++ {
149+
if nodes[j].StartPage > 0 {
150+
if nodes[j].StartPage-1 >= nodes[i].StartPage {
151+
return nodes[j].StartPage - 1
152+
}
153+
return nodes[i].StartPage // degenerate ordering: single page
154+
}
155+
}
156+
if parentEnd > 0 {
157+
return parentEnd
158+
}
159+
return nodes[i].StartPage
160+
}
161+
162+
// bestHeadingPath picks the heading path for a section spanning
163+
// [secStart, secEnd] using the precedence documented on
164+
// BuildHeadingPaths. Returns ok=false when no TOC entry overlaps the
165+
// section at all.
166+
func bestHeadingPath(entries []tocEntry, secStart, secEnd int) ([]string, bool) {
167+
bestIdx := -1
168+
for i, e := range entries {
169+
ov := overlapPages(e.start, e.end, secStart, secEnd)
170+
if ov <= 0 {
171+
continue
172+
}
173+
if bestIdx < 0 || lessSpecific(entries[bestIdx], e, secStart, secEnd) {
174+
bestIdx = i
175+
}
176+
}
177+
if bestIdx < 0 || len(entries[bestIdx].path) == 0 {
178+
return nil, false
179+
}
180+
// Defensive copy so callers can't mutate our internal slices.
181+
return append([]string(nil), entries[bestIdx].path...), true
182+
}
183+
184+
// lessSpecific reports whether the current best entry a is a WORSE match
185+
// for the section than candidate b — i.e. b should replace a. The
186+
// ordering mirrors the BuildHeadingPaths precedence list.
187+
func lessSpecific(a, b tocEntry, secStart, secEnd int) bool {
188+
aContains := contains(a.start, a.end, secStart, secEnd)
189+
bContains := contains(b.start, b.end, secStart, secEnd)
190+
if aContains != bContains {
191+
return bContains // prefer the container
192+
}
193+
if a.depth != b.depth {
194+
return b.depth > a.depth // prefer deeper / more specific
195+
}
196+
aOv := overlapPages(a.start, a.end, secStart, secEnd)
197+
bOv := overlapPages(b.start, b.end, secStart, secEnd)
198+
if aOv != bOv {
199+
return bOv > aOv // prefer more overlap
200+
}
201+
if a.span() != b.span() {
202+
return b.span() < a.span() // prefer the tighter node
203+
}
204+
return b.start < a.start // deterministic tie-break
205+
}
206+
207+
// contains reports whether [oStart,oEnd] fully encloses [iStart,iEnd].
208+
func contains(oStart, oEnd, iStart, iEnd int) bool {
209+
return oStart <= iStart && iEnd <= oEnd
210+
}
211+
212+
// overlapPages returns the count of shared inclusive pages between two
213+
// ranges, or 0 when they don't intersect.
214+
func overlapPages(aStart, aEnd, bStart, bEnd int) int {
215+
if aStart <= 0 || aEnd <= 0 || bStart <= 0 || bEnd <= 0 {
216+
return 0
217+
}
218+
lo := max(aStart, bStart)
219+
hi := min(aEnd, bEnd)
220+
if hi < lo {
221+
return 0
222+
}
223+
return hi - lo + 1
224+
}
225+
226+
// documentMaxPage is the highest page the document is known to reach,
227+
// used to bound open-ended top-level TOC nodes. It takes the max across
228+
// section PageEnds and TOC StartPages so a TOC whose last node has no
229+
// EndPage still resolves to something sane.
230+
func documentMaxPage(root *Section, toc []TOCNode) int {
231+
hi := 0
232+
if root != nil {
233+
root.Walk(func(s *Section) bool {
234+
if s != nil {
235+
hi = max(hi, s.PageEnd)
236+
}
237+
return true
238+
})
239+
}
240+
var scan func(nodes []TOCNode)
241+
scan = func(nodes []TOCNode) {
242+
for _, n := range nodes {
243+
hi = max(hi, n.EndPage, n.StartPage)
244+
scan(n.Nodes)
245+
}
246+
}
247+
scan(toc)
248+
return hi
249+
}
250+
251+
// normaliseTitle trims a TOC title for use as a path segment. It only
252+
// strips surrounding whitespace — the bench's anchor matcher already
253+
// handles case/punctuation/ordinal normalisation, so we keep the
254+
// heading verbatim here and let the consumer normalise for comparison.
255+
func normaliseTitle(s string) string {
256+
return strings.TrimSpace(s)
257+
}

0 commit comments

Comments
 (0)