Coverage for src / quber / core / fusion / footnotes.py: 96%

141 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""Resolve a table's footnote reference markers to their definition text. 

2 

3A marker printed on a table cell points at a footnote, but the footnote's 

4text can be printed in three places, each needing its own resolution step: 

5 

61. Below the table, inside the correction agent's crop. The agent already 

7 transcribed those as marker-and-text pairs (`FootnoteDef`), so pairing is 

8 a normalized-key match — `(1)`, `1` and a superscript one are the same 

9 marker. The crop ends where the page does, so a footnote that continues on 

10 the next page reaches the agent cut mid-sentence; when the scan tier's line 

11 for the same marker opens with everything the agent read and continues 

12 past it, that longer line is taken as the definition instead. 

132. Beyond the crop: later on the page or on a following page, including a 

14 continuation block (markers `a` through `j` printed overleaf). Those are 

15 found by scanning the document's text lines in reading order after the 

16 table. Acceptance requires coherence: a matching line on the table's own 

17 page is trusted (it sits right under the table), and so is a line inside 

18 a run of consecutive marker-opened lines (the shape of a real 

19 continuation block). A solitary line whose leading token happens to be a 

20 digit, found on a distant page, proves nothing and is refused. 

213. Nowhere as a footnote at all: a cross-reference to a NAMED SECTION of 

22 the document — `(Note 12)`, `Schedule II`, `(Addendum 3)` — points at a 

23 whole section, not a footnote line. WHICH markers are section references 

24 is the correction agent's judgement, made from the page image and carried 

25 on each mark's `kind`; nothing here infers it from the marker's wording. 

26 A section reference resolves to a POINTER — the heading that opens with 

27 the reference as written, plus its page — never to inlined text. 

28 

29Everything here is pure and deterministic. The demand-driven lookup agent 

30(`quber.agents.footnote_lookup`) is the third tier for markers this module 

31leaves unresolved; callers invoke it separately so the common case costs no 

32model call. Every marker either resolves or lands in `unresolved`, and every 

33marked definition either matches a marker or lands in `unreferenced` — 

34nothing is silently dropped. Unmarked general notes are not exceptions: they 

35carry no marker to match, and the consumer attaches them at table level. 

36""" 

37 

38from __future__ import annotations 

39 

40import re 

41from typing import Collection, List, Literal, Optional, Sequence, Tuple 

42 

43from pydantic import BaseModel, Field 

44 

45from quber.agents.llm_client import FootnoteDef 

46 

47 

48class FootnoteResolution(BaseModel): 

49 """The complete resolution for one table's markers, with its exceptions.""" 

50 

51 resolved: List["ResolvedFootnote"] = Field(default_factory=list) 

52 unresolved: List[str] = Field( 

53 default_factory=list, 

54 description="Markers (as printed) whose definition was not found in any tier", 

55 ) 

56 unreferenced: List[FootnoteDef] = Field( 

57 default_factory=list, 

58 description="Marked definitions that no marker on the table points at", 

59 ) 

60 

61 def text_for(self, marker: str) -> Optional[str]: 

62 """The resolved text for a printed marker, matched by canonical key.""" 

63 key = canonical_marker(marker) 

64 for r in self.resolved: 

65 if canonical_marker(r.marker) == key: 

66 return r.text 

67 return None 

68 

69 

70class ResolvedFootnote(BaseModel): 

71 """One marker paired with the text a reader should see for it.""" 

72 

73 marker: str = Field(description="The marker as printed on the table") 

74 text: str = Field(description="The definition text, or the pointer line for a Notes reference") 

75 source: Literal["table", "scan", "note_pointer", "lookup", "sibling"] = Field( 

76 description=( 

77 "table: paired in the agent's own crop. scan: found by the reading-order " 

78 "scan beyond the crop. note_pointer: a Notes-section reference resolved to " 

79 "its heading and page. lookup: found by the demand-driven lookup agent. " 

80 "sibling: a definition printed under a neighboring table of the same series." 

81 ) 

82 ) 

83 

84 

85# Superscript glyphs normalize to their plain forms so a superscript one and a 

86# printed '(1)' key the same. 

87_SUPERSCRIPTS = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏ", "0123456789abcdefghijk") 

88 

89 

90# The leading token of a printed footnote line. Digits and symbols may stand 

91# bare ('2 Includes...'); letters must be set off by parentheses or trailing 

92# punctuation ('a.' / '(a)'), because an ordinary prose line also starts with 

93# a short word and would otherwise read as a marker. 

94_LEAD_MARKER_RE = re.compile( 

95 r"^(?:" 

96 r"\((?P<paren>\d{1,2}|[A-Za-z]{1,2}|[*†‡§]{1,3})\)" 

97 r"|(?P<bare>\d{1,2}|[*†‡§]{1,3})" 

98 r"|(?P<punct>[A-Za-z]{1,2})[.):]" 

99 r")\s+" 

100) 

101 

102 

103def canonical_marker(marker: str) -> str: 

104 """A marker's identity independent of rendering: '(1)', '1' and a 

105 superscript one all key to '1'; letters casefold.""" 

106 s = re.sub(r"\s+", "", marker or "").translate(_SUPERSCRIPTS) 

107 return s.strip("()[]").rstrip(".:").casefold() 

108 

109 

110def split_leading_marker(line: str) -> Optional[Tuple[str, str]]: 

111 """(canonical marker, rest of line) when `line` opens the way a printed 

112 footnote does; None otherwise. The rest must be non-empty — a marker 

113 with nothing after it defines nothing.""" 

114 m = _LEAD_MARKER_RE.match(line.strip()) 

115 if not m: 

116 return None 

117 token = m.group("paren") or m.group("bare") or m.group("punct") 

118 rest = line.strip()[m.end() :].strip() 

119 if not rest: 

120 return None 

121 return canonical_marker(token), rest 

122 

123 

124def resolve_footnotes( 

125 markers: Sequence[str], 

126 footnotes: Sequence[FootnoteDef], 

127 table_page: int, 

128 trailing_lines: Sequence[Tuple[str, int]] = (), 

129 headings: Sequence[Tuple[str, int]] = (), 

130 section_keys: Collection[str] = frozenset(), 

131) -> FootnoteResolution: 

132 """Resolve every distinct marker on one table, in tier order. 

133 

134 `markers` are the table's printed reference markers (the located marks 

135 plus the catalogued refs). `footnotes` are the agent's in-crop pairs. 

136 `trailing_lines` are the document's text lines in reading order after the 

137 table, each with its 1-indexed page — the scan tier's universe. 

138 `headings` are the document's section headings with their pages. 

139 `section_keys` are the canonical keys of the markers the correction agent 

140 judged to be cross-references to a named section of the document — that 

141 judgement is the agent's, made from the page image; nothing here infers 

142 it from the marker's wording or shape. A section reference resolves to a 

143 heading pointer or stays unresolved; it is never hunted as a footnote. 

144 Returns the resolution plus both exception lists; markers still 

145 unresolved here are the lookup agent's demand. 

146 """ 

147 defs_by_key: dict[str, FootnoteDef] = {} 

148 for d in footnotes: 

149 key = canonical_marker(d.marker) 

150 if key: 

151 defs_by_key.setdefault(key, d) 

152 

153 parsed_lines = [(split_leading_marker(text), page) for text, page in trailing_lines] 

154 # Runs of consecutive marker-opened lines: the shape of a continuation 

155 # block. block_len[i] is the length of the run line i belongs to. 

156 block_len = [0] * len(parsed_lines) 

157 i = 0 

158 while i < len(parsed_lines): 

159 if parsed_lines[i][0] is None: 

160 i += 1 

161 continue 

162 j = i 

163 while j < len(parsed_lines) and parsed_lines[j][0] is not None: 

164 j += 1 

165 for k in range(i, j): 

166 block_len[k] = j - i 

167 i = j 

168 

169 resolved: List[ResolvedFootnote] = [] 

170 unresolved: List[str] = [] 

171 seen_keys: set[str] = set() 

172 for printed in markers: 

173 key = canonical_marker(printed) 

174 if not key or key in seen_keys: 

175 continue 

176 seen_keys.add(key) 

177 

178 if key in section_keys: 

179 pointer = _section_pointer(printed, headings) 

180 if pointer: 

181 resolved.append(ResolvedFootnote(marker=printed, text=pointer, source="note_pointer")) 

182 else: 

183 unresolved.append(printed) 

184 continue 

185 

186 candidates = _scan_candidates(key, parsed_lines, block_len, table_page) 

187 

188 if key in defs_by_key: 

189 in_crop = defs_by_key[key].text 

190 # The crop ends where the page does. A footnote whose text runs on 

191 # to the next page reaches the agent cut mid-sentence, while the 

192 # parse stitches both pages into one line. When a line the scan 

193 # accepts for the same marker opens with everything the agent read 

194 # and continues past it, that line is the complete definition. The 

195 # agent's own text also rides in the scan universe (the graft 

196 # attaches it to the table), so the first match is often the same 

197 # cut text; every accepted line is tried. 

198 longer = next((c for c in candidates if _extends(in_crop, c)), None) 

199 if longer is not None: 

200 resolved.append(ResolvedFootnote(marker=printed, text=longer, source="scan")) 

201 else: 

202 resolved.append(ResolvedFootnote(marker=printed, text=in_crop, source="table")) 

203 continue 

204 

205 if candidates: 

206 resolved.append(ResolvedFootnote(marker=printed, text=candidates[0], source="scan")) 

207 continue 

208 

209 # A marker the agent judged a footnote but that no tier defines can 

210 # still be a mislabelled section reference. The test is the same one 

211 # section references pass: a document heading literally opens with 

212 # the printed marker. Restricted to word-plus-number markers 

213 # ('Note 18') — a bare '(1)' or '(a)' can never fall through to a 

214 # heading, so a genuine footnote marker with a missing definition 

215 # stays unresolved and flagged. 

216 if ( 

217 any(ch.isalpha() for ch in printed) 

218 and any(ch.isdigit() for ch in printed) 

219 and (pointer := _section_pointer(printed, headings)) 

220 ): 

221 resolved.append(ResolvedFootnote(marker=printed, text=pointer, source="note_pointer")) 

222 else: 

223 unresolved.append(printed) 

224 

225 unreferenced = [ 

226 d for d in footnotes if canonical_marker(d.marker) and canonical_marker(d.marker) not in seen_keys 

227 ] 

228 return FootnoteResolution(resolved=resolved, unresolved=unresolved, unreferenced=unreferenced) 

229 

230 

231def absorb_lookup( 

232 resolution: FootnoteResolution, 

233 found: Sequence[FootnoteDef], 

234 source: Literal["lookup", "sibling"] = "lookup", 

235) -> FootnoteResolution: 

236 """Fold externally found definitions into a resolution. 

237 

238 Each found pair matching a still-unresolved marker resolves it under 

239 `source`; markers not found stay unresolved, and the rest of the 

240 resolution is unchanged.""" 

241 by_key = {canonical_marker(d.marker): d for d in found if canonical_marker(d.marker)} 

242 resolved = list(resolution.resolved) 

243 unresolved: List[str] = [] 

244 for printed in resolution.unresolved: 

245 d = by_key.get(canonical_marker(printed)) 

246 if d is not None: 

247 resolved.append(ResolvedFootnote(marker=printed, text=d.text, source=source)) 

248 else: 

249 unresolved.append(printed) 

250 return FootnoteResolution( 

251 resolved=resolved, unresolved=unresolved, unreferenced=list(resolution.unreferenced) 

252 ) 

253 

254 

255def _extends(shorter: str, longer: str) -> bool: 

256 """Whether `longer` opens with the whole of `shorter` and continues past 

257 it, compared word by word with case, spacing and punctuation dropped so 

258 the two extractors' renderings of the same printed line agree. A hyphen 

259 dropped at a line break ('interest-only' against 'interestonly') is the 

260 remaining difference, so the comparison also joins the words.""" 

261 a = _words(shorter) 

262 b = _words(longer) 

263 if not a or len(b) <= len(a): 

264 return False 

265 if b[: len(a)] == a: 

266 return True 

267 joined_a = "".join(a) 

268 joined_b = "".join(b) 

269 return len(joined_b) > len(joined_a) and joined_b.startswith(joined_a) 

270 

271 

272def _words(text: str) -> List[str]: 

273 return re.findall(r"[a-z0-9]+", text.casefold()) 

274 

275 

276def _scan_candidates( 

277 key: str, 

278 parsed_lines: Sequence[Tuple[Optional[Tuple[str, str]], int]], 

279 block_len: Sequence[int], 

280 table_page: int, 

281) -> List[str]: 

282 """The scan tier: every coherent line opening with `key`, in reading order. 

283 

284 Coherent means the line is on the table's own page (it sits right under 

285 the table), or it belongs to a run of two or more consecutive 

286 marker-opened lines (a continuation block), or its marker is not purely 

287 numeric (a bare digit is the one leading token common prose also 

288 produces). The remaining case — a solitary numeric match on a distant 

289 page — is refused. The first entry is the scan tier's own answer; the rest 

290 matter only when the agent's in-crop text is being checked for a longer 

291 rendering of the same footnote. 

292 """ 

293 found: List[str] = [] 

294 for (parsed, page), run in zip(parsed_lines, block_len, strict=True): 

295 if parsed is None or parsed[0] != key: 

296 continue 

297 if page == table_page or run >= 2 or not key.isdigit(): 

298 found.append(parsed[1]) 

299 return found 

300 

301 

302def _section_pointer(printed: str, headings: Sequence[Tuple[str, int]]) -> Optional[str]: 

303 """The pointer line for a section reference: the target's heading and page. 

304 

305 The reference as the agent wrote it, normalized (spacing, punctuation, 

306 case dropped), must be the opening of a heading — 'Note 16' opens 

307 'NOTE 16. Commitments and Contingencies', 'Schedule II' opens 

308 'Schedule II — Valuation and Qualifying Accounts'. Two printed forms of 

309 the same target also agree: a reference written with a word where the 

310 heading opens with the bare number (or the reverse) matches on the 

311 number — 'Note 10' points at '10. Contingencies' — but only when the 

312 reference itself is a word-plus-number, never for a bare digit, and only 

313 at a digit boundary so '10' cannot open '104'. A reference naming 

314 several targets ('Notes 1 and 4') resolves when every number it names 

315 finds a heading; the pointer then lists them all. None when no heading 

316 matches — the reference then stays unresolved and flagged rather than 

317 pointing at nothing.""" 

318 key = _heading_key(printed) 

319 if not key: 

320 return None 

321 for text, page in headings: 

322 if _heading_key(text).startswith(key): 

323 return f"See {text.strip()} (page {page})" 

324 

325 lead = re.match(r"([a-z]+)\d", key) 

326 if not lead: 

327 return None 

328 ref_word = lead.group(1) 

329 pointers = [] 

330 for number in re.findall(r"\d+", key): 

331 hit = None 

332 for text, page in headings: 

333 m = re.match(r"([a-z]*)(\d+)", _heading_key(text)) 

334 if m is None or m.group(2) != number: 

335 continue 

336 hword = m.group(1) 

337 if hword and not (hword.startswith(ref_word) or ref_word.startswith(hword)): 

338 continue 

339 hit = f"See {text.strip()} (page {page})" 

340 break 

341 if hit is None: 

342 return None 

343 pointers.append(hit) 

344 return "; ".join(pointers) if pointers else None 

345 

346 

347def _heading_key(text: str) -> str: 

348 """Text reduced to its letters and digits, casefolded — the form in which 

349 a printed reference and its target heading agree regardless of spacing or 

350 punctuation.""" 

351 return "".join(ch for ch in text if ch.isalnum()).casefold()