Coverage for src / quber / core / extractors / set_of_mark / merge_grounding.py: 98%

392 statements  

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

1"""Ground the corrected table's cells to Camelot's measured cell boxes. 

2 

3The structure-correction agent reads the Camelot grid inside a printed 

4spreadsheet coordinate frame (lettered columns, numbered rows), so for every 

5cell it builds by joining Camelot cells it reports the source ADDRESSES it 

6read off that frame — `CellMerge.source_cells`, e.g. ["B2", "B3"] — alongside 

7the verbatim source texts. Grounding is then a pure lookup: an address names a 

8`cell_grid` cell, and that cell's Camelot-measured box is the geometry. The 

9model never produces a coordinate and never counts positions; it repeats 

10printed labels, and a lookup resolves them. 

11 

12Every report is validated before it is trusted: the text at the claimed 

13address must appear in the merge's result. A misread address is detected and 

14refused rather than absorbed, so a wrong box can enter the record only if the 

15wrong cell holds the exact same text — never as an invented or unchecked 

16location. Refusals and unreported addresses leave the merge `partial` or 

17`none`, warned in the run log. 

18 

19The same stage closes the whole table. `resolve_corrected_grid` aligns the 

20corrected markdown to the Camelot grid in reading order — rows first, then 

21cells within each aligned row — matching whole cells on exact text equality, 

22so a value moved by the correction keeps its measured box and identical twins 

23map in order. Combined cells take the box their merge resolved to. A second 

24pass then closes the cells whole-cell matching cannot reach: Camelot sometimes 

25glues several printed columns into one grid cell and wraps one printed label 

26across consecutive grid rows, so the corrected cells are *fragments* of grid 

27text. Those are matched as ordered contiguous spans of their aligned rows' 

28text, each taking the union of the grid-cell boxes its span touches. A cell 

29that matches nothing keeps box=None and is warned by address — a token with no 

30home in the grid (e.g. a placeholder the agent invented) never acquires a box. 

31 

32After grounding, `classify_cells` sets a status on every non-empty corrected 

33cell so a missing box reads as what it is: `reconciled` (tied to a printed 

34source with measured coordinates), `header_printed_unlocated` / 

35`label_printed_unlocated` (printed in the source but coordinates not 

36measured, split by row kind), `single_character` (excluded from matching by 

37design), `total_label_added` / `header_label_added` (no printed source — an 

38authorized conventional label the correction added, split by row kind), 

39`unverified` (no printed source and not an authorized label — the catch-all 

40for conditions outside every cataloged classification). Added-label and 

41unverified cells are surfaced for user inspection. The full glossary lives 

42in this package's README.md. 

43 

44Three closure passes then handle the transformations reading-order matching 

45cannot express. Header stacks: a flattened multi-row header's fragments sit in 

46the grid's header region stacked within one column, so an unboxed header cell 

47takes the union of the column run whose concatenation equals its text, with 

48repeated labels pairing to candidate stacks in reading order. Unpaired rows: 

49when the agent rebuilds two stacked printed sections side by side, each 

50corrected row's other-section cells match as ordered spans of a grid row the 

51alignment never consumed. Marker labels: a row label the correction extended 

52with a footnote marker it catalogued in `footnote_refs` matches with the 

53marker stripped. Header merges are also region-checked — a header cell's 

54source must sit above the grid's first value row, because header text often 

55repeats in the data region and the text check alone would let a misread 

56address anchor a header to a data cell. Nothing downstream re-derives or 

57reconciles geometry. 

58""" 

59 

60from __future__ import annotations 

61 

62import re 

63from typing import Dict, List, Literal, Optional, Sequence, Tuple 

64 

65from loguru import logger 

66 

67from quber.agents.llm_client import CellMerge, FootnoteDef, FootnoteMark 

68from quber.core.extractors.base import AUTHORIZED_LABELS, GroundedCell, LocatedMarker, MergedCellBox 

69 

70Box = Tuple[float, float, float, float] 

71 

72_ADDRESS_RE = re.compile(r"^([A-Za-z]+)\s*(\d+)$") 

73 

74# A money/figure cell: currency, parenthesized negative, thousands comma, or a 

75# decimal. Used to find where a table's data begins — its header rows sit above 

76# the first row carrying two or more of these. 

77_VALUE_RE = re.compile(r"\$|\(\d|\d,\d|\d+\.\d") 

78 

79 

80def _norm(s: str) -> str: 

81 return re.sub(r"\s+", "", s or "") 

82 

83 

84def _first_value_row(rows: Sequence[Sequence[str]]) -> Optional[int]: 

85 """Index of the first row carrying at least two money/figure cells; None 

86 when no row does (a prose table has no data boundary).""" 

87 for i, row in enumerate(rows): 

88 if sum(1 for v in row if _VALUE_RE.search(v)) >= 2: 

89 return i 

90 return None 

91 

92 

93def _union(boxes: Sequence[Box]) -> Box: 

94 return ( 

95 min(b[0] for b in boxes), 

96 min(b[1] for b in boxes), 

97 max(b[2] for b in boxes), 

98 max(b[3] for b in boxes), 

99 ) 

100 

101 

102def markdown_rows(markdown: str) -> List[List[str]]: 

103 """The markdown table's rows as cell lists; row 0 is the header row and the 

104 separator line is skipped, matching `CellMerge.row` addressing.""" 

105 rows: List[List[str]] = [] 

106 for line in (markdown or "").splitlines(): 

107 line = line.strip() 

108 if not line.startswith("|"): 

109 continue 

110 parts = [p.strip() for p in line.strip("|").split("|")] 

111 if parts and any("-" in p for p in parts) and all(set(p) <= {"-", ":", " "} for p in parts): 

112 continue 

113 rows.append(parts) 

114 return rows 

115 

116 

117def parse_address(address: str) -> Optional[Tuple[int, int]]: 

118 """A printed spreadsheet address to 0-based (grid row, grid column). 

119 

120 'B3' names grid row 3 (1-based, as printed in the row-number column) and 

121 data column B (A=0). Anything that does not parse returns None. 

122 """ 

123 m = _ADDRESS_RE.match((address or "").strip()) 

124 if not m: 

125 return None 

126 letters, number = m.group(1).upper(), int(m.group(2)) 

127 if number < 1: 

128 return None 

129 col = 0 

130 for ch in letters: 

131 col = col * 26 + (ord(ch) - ord("A") + 1) 

132 return number - 1, col - 1 

133 

134 

135def resolve_merges( 

136 merges: Sequence[CellMerge], 

137 cell_grid: Sequence[Sequence[GroundedCell]], 

138 markdown: str = "", 

139) -> List[MergedCellBox]: 

140 """Resolve every merge's reported source addresses to measured boxes. 

141 

142 Pure lookup plus validation: each address names a `cell_grid` cell, and it 

143 is trusted only if that cell's text appears in the merge's result. An 

144 address that does not parse, is out of bounds, names a blank cell, fails 

145 the text check, or has no Camelot box resolves to None. `box` is the union 

146 of the located source boxes. All boxes are `cell_grid`'s own — normalized 

147 0..1, page top-left origin. 

148 

149 When the corrected `markdown` is given, a merge in a HEADER row (above the 

150 corrected table's first value row) may only cite sources above the grid's 

151 own first value row. Header text often repeats in the data region (an 

152 equity statement prints 'Treasury' in both), so the text check alone would 

153 let a misread address anchor a header cell to a data cell's box; the 

154 region constraint refuses it instead. 

155 """ 

156 corrected_first = _first_value_row(markdown_rows(markdown)) if markdown else None 

157 grid_boundary = _first_value_row([[c.text for c in grow] for grow in cell_grid]) 

158 

159 def in_data_region(address: str) -> bool: 

160 parsed = parse_address(address) 

161 return parsed is not None and grid_boundary is not None and parsed[0] >= grid_boundary 

162 

163 out: List[MergedCellBox] = [] 

164 for m in merges: 

165 header_merge = corrected_first is not None and m.row < corrected_first 

166 pieces = m.sources if len(m.sources) == len(m.source_cells) else [""] * len(m.source_cells) 

167 source_boxes: List[Optional[Box]] = [ 

168 None if header_merge and in_data_region(address) else _lookup(address, m.result, piece, cell_grid) 

169 for address, piece in zip(m.source_cells, pieces, strict=False) 

170 ] 

171 located = [b for b in source_boxes if b is not None] 

172 if not m.source_cells or not located: 

173 grounded = "none" 

174 elif len(located) == len(source_boxes): 

175 grounded = "cell_address" 

176 else: 

177 grounded = "partial" 

178 out.append( 

179 MergedCellBox( 

180 result=m.result, 

181 row=m.row, 

182 col=m.col, 

183 sources=list(m.sources), 

184 source_cells=list(m.source_cells), 

185 source_boxes=source_boxes, 

186 box=_union(located) if located else None, 

187 grounded_by=grounded, 

188 ) 

189 ) 

190 return out 

191 

192 

193def _lookup( 

194 address: str, result: str, piece: str, cell_grid: Sequence[Sequence[GroundedCell]] 

195) -> Optional[Box]: 

196 parsed = parse_address(address) 

197 if parsed is None: 

198 return None 

199 row, col = parsed 

200 if not (0 <= row < len(cell_grid) and 0 <= col < len(cell_grid[row])): 

201 return None 

202 cell = cell_grid[row][col] 

203 text = _norm(cell.text) 

204 if not text: 

205 return None 

206 # The claim must be text-anchored, one of two ways: the cell's text appears 

207 # in the result (the normal case), or the paired source piece appears in the 

208 # cell's text (Camelot glued extra fragments into the cell, so the cell 

209 # contains more than the merge used — the cell still IS the piece's home). 

210 # A misread address matching neither is refused, never absorbed. 

211 if text in _norm(result): 

212 return cell.box 

213 p = _norm(piece) 

214 if p and p in text: 

215 return cell.box 

216 return None 

217 

218 

219def resolve_corrected_grid( 

220 markdown: str, 

221 merged: Sequence[MergedCellBox], 

222 cell_grid: Sequence[Sequence[GroundedCell]], 

223 footnote_refs: Sequence[str] = (), 

224 region_text: str = "", 

225) -> List[List[GroundedCell]]: 

226 """The corrected markdown's grid with a measured box on every provable cell. 

227 

228 Combined cells take the box their merge resolved to, at the address the 

229 merge reported — but only when the corrected cell at that address actually 

230 holds the merge's result, so a miscounted position can never plant a box 

231 on the wrong cell. Every other cell is matched to the Camelot grid by an 

232 order-preserving alignment: corrected rows align to grid rows by shared 

233 values (correction never reorders rows), and within an aligned row pair 

234 cells match left-to-right on exact text equality (correction never 

235 reorders a row's values, so identical twins map in printed order). No 

236 global value search, no context scoring — a cell either aligns exactly or 

237 keeps box=None, warned by `log_ungrounded_cells`. 

238 """ 

239 rows = markdown_rows(markdown) 

240 if not rows: 

241 return [] 

242 width = max(len(r) for r in rows) 

243 padded = [row + [""] * (width - len(row)) for row in rows] 

244 

245 grid_keys = [[_norm(c.text) for c in grow] for grow in cell_grid] 

246 row_keys = [[_norm(v) for v in row] for row in padded] 

247 

248 # A merge's box is planted only when the merge is FULLY grounded and the 

249 # corrected cell at its reported position actually holds the result. The 

250 # sources are text-validated, but row/col is the agent COUNTING positions 

251 # in its own output — a miscount would plant a correct box on the wrong 

252 # cell, which no later check could see (a planted cell reads as boxed). 

253 # A partial merge's union is just as treacherous in extent: with the 

254 # number's source refused it can cover only the '$' symbol, a box that 

255 # misses the value it claims. Both fall through to the alignment and 

256 # closure passes, which box the true cell from the grid text or leave an 

257 # honest warned gap. 

258 merged_boxes: Dict[Tuple[int, int], Box] = {} 

259 for m in merged: 

260 if m.box is None or m.grounded_by != "cell_address": 

261 continue 

262 if 0 <= m.row < len(row_keys) and 0 <= m.col < len(row_keys[m.row]): 

263 if row_keys[m.row][m.col] == _norm(m.result): 

264 merged_boxes[(m.row, m.col)] = m.box 

265 

266 boxes: Dict[Tuple[int, int], Box] = dict(merged_boxes) 

267 pairs = _align_rows(row_keys, grid_keys) 

268 for r, g in pairs: 

269 gi = 0 

270 grow = cell_grid[g] 

271 gkeys = grid_keys[g] 

272 for c, key in enumerate(row_keys[r]): 

273 if not key or (r, c) in boxes: 

274 continue 

275 found = _consume_run(grow, gkeys, gi, key) 

276 if found is None: 

277 continue 

278 end, matched = found 

279 if matched is not None: 

280 boxes[(r, c)] = matched 

281 gi = end 

282 # Span tiling is for VALUE rows: within one printed row, row-major char 

283 # order matches reading order. In a stacked header region it does not — 

284 # fragments interleave across columns row by row, so a stream span can 

285 # falsely bridge two columns' fragments. Header rows are closed by the 

286 # column-stack pass instead. 

287 corrected_first = _first_value_row(row_keys) 

288 _tile_windows(pairs, row_keys, grid_keys, cell_grid, boxes, skip_before=corrected_first or 0) 

289 _close_header_stacks(row_keys, grid_keys, cell_grid, boxes) 

290 _close_unpaired_rows(pairs, row_keys, grid_keys, cell_grid, boxes) 

291 _close_marker_labels(pairs, row_keys, grid_keys, cell_grid, boxes, footnote_refs) 

292 

293 grid = [ 

294 [GroundedCell(text=text, box=boxes.get((r, c))) for c, text in enumerate(row)] 

295 for r, row in enumerate(padded) 

296 ] 

297 classify_cells(grid, cell_grid, region_text) 

298 return grid 

299 

300 

301def classify_cells( 

302 grid: Sequence[Sequence[GroundedCell]], 

303 cell_grid: Sequence[Sequence[GroundedCell]], 

304 region_text: str = "", 

305) -> None: 

306 """Set a status on every non-empty corrected cell, so a missing box reads 

307 as what it is instead of as an undifferentiated failure. 

308 

309 The deciding test for an unboxed cell is whether its text exists in the 

310 table's SOURCE — the Camelot grid plus the region's text layer. Present 

311 means the correction preserved printed text the matcher does not reach 

312 (split header/band rows from value rows, since the two gaps close 

313 differently). Absent splits two ways: text that is one of the 

314 conventional labels the correction is AUTHORIZED to add gets an 

315 added-label status, split by row kind exactly like the printed statuses — 

316 'Total' on an unlabeled totals row is `total_label_added`, a generic 

317 column name on a table printed without a header row is 

318 `header_label_added`; any other unverifiable text is `unverified` — a 

319 condition outside every cataloged classification, named as such rather 

320 than folded into a known class. All three are surfaced for user 

321 inspection. One-character cells are their own class: excluded from 

322 matching by design, because a bare symbol or digit would anchor inside 

323 any unrelated number. Source texts join on a sentinel so a token can 

324 never match across two cells' seam. 

325 """ 

326 source = _norm("\x00".join(c.text for grow in cell_grid for c in grow if c.text) + "\x00" + region_text) 

327 value_rows = {r for r, row in enumerate(grid) if sum(1 for c in row if _VALUE_RE.search(c.text)) >= 2} 

328 for r, row in enumerate(grid): 

329 for cell in row: 

330 if not cell.text.strip(): 

331 continue 

332 if cell.box is not None: 

333 cell.status = "reconciled" 

334 continue 

335 key = _norm(cell.text) 

336 if len(key) < 2: 

337 cell.status = "single_character" 

338 continue 

339 tokens = [t for t in (_norm(p) for p in cell.text.split()) if len(t) >= 2] or [key] 

340 if all(t in source for t in tokens): 

341 cell.status = "label_printed_unlocated" if r in value_rows else "header_printed_unlocated" 

342 elif key.casefold() in AUTHORIZED_LABELS: 

343 cell.status = "total_label_added" if r in value_rows else "header_label_added" 

344 else: 

345 cell.status = "unverified" 

346 

347 

348def _tile_windows( 

349 pairs: Sequence[Tuple[int, int]], 

350 row_keys: Sequence[Sequence[str]], 

351 grid_keys: Sequence[Sequence[str]], 

352 cell_grid: Sequence[Sequence[GroundedCell]], 

353 boxes: Dict[Tuple[int, int], Box], 

354 skip_before: int = 0, 

355) -> None: 

356 """Box the corrected cells whole-cell matching cannot reach: fragments of 

357 glued Camelot cells, matched as ordered contiguous spans of their window. 

358 

359 Camelot sometimes packs several printed columns into one grid cell 

360 ('(135)\\n (93)\\n (14,880)') and wraps one printed label across 

361 consecutive grid rows; the corrected cell's text is then a fragment of 

362 grid text, never equal to any whole cell. For each aligned row pair the 

363 window is the paired grid row plus the unpaired grid rows since the 

364 previous pair (where a wrapped label's other lines live). Every char of 

365 the window's text carries its grid cell's box; a still-unboxed corrected 

366 cell found as a contiguous span at or after the cursor takes the union of 

367 the boxes its span touches. The cursor only moves forward, so matches keep 

368 printed order and repeated values cannot cross. A key shorter than two 

369 normalized chars is never span-matched (a bare digit would anchor inside 

370 an unrelated number). A cell that matches nothing keeps no box — a token 

371 absent from the grid has no home and never acquires one. Corrected rows 

372 before `skip_before` (the header region) are never tiled: their fragments 

373 interleave across columns, where a stream span can falsely bridge two 

374 columns; the column-stack closure owns them. 

375 """ 

376 prev_g = -1 

377 for r, g in pairs: 

378 window = range(prev_g + 1, g + 1) 

379 prev_g = g 

380 if r < skip_before: 

381 continue 

382 if all(not key or (r, c) in boxes for c, key in enumerate(row_keys[r])): 

383 continue 

384 stream: List[Optional[Box]] = [] 

385 text = "" 

386 for gi in window: 

387 for cell, key in zip(cell_grid[gi], grid_keys[gi], strict=False): 

388 stream.extend([cell.box] * len(key)) 

389 text += key 

390 pos = 0 

391 for c, key in enumerate(row_keys[r]): 

392 if not key: 

393 continue 

394 if (r, c) in boxes: 

395 # Already boxed by a merge or a whole-cell match: advance the 

396 # cursor past it when findable, so later spans stay ordered. 

397 at = text.find(key, pos) 

398 if at >= 0: 

399 pos = at + len(key) 

400 continue 

401 if len(key) < 2: 

402 continue 

403 at = text.find(key, pos) 

404 if at < 0: 

405 continue 

406 span = [b for b in stream[at : at + len(key)] if b is not None] 

407 if span: 

408 boxes[(r, c)] = _union(span) 

409 pos = at + len(key) 

410 

411 

412def _close_header_stacks( 

413 row_keys: Sequence[Sequence[str]], 

414 grid_keys: Sequence[Sequence[str]], 

415 cell_grid: Sequence[Sequence[GroundedCell]], 

416 boxes: Dict[Tuple[int, int], Box], 

417) -> None: 

418 """Box header cells the agent flattened from a stacked printed header. 

419 

420 A multi-row header's fragments live in the grid's HEADER REGION (the rows 

421 above its first value row) stacked within ONE grid column — 'Additional' / 

422 'Paid-In' / 'Capital' each in its own row of the same column. So an 

423 unboxed corrected header cell closes deterministically: find the grid 

424 column whose top-to-bottom fragment run concatenates to exactly the cell's 

425 text (or a single glued fragment containing it), and take the union of the 

426 run's boxes. A spanning label printed once ('Common Stock' over Shares and 

427 Dollars) serves every corrected column it spans. Repeated labels — twin 

428 period bands, identical '% of' stacks — pair with candidate stacks 

429 positionally in reading order, the same twins-in-order rule the row 

430 alignment uses. Merge reporting still takes precedence: only cells the 

431 merges and the alignment left unboxed are considered. 

432 """ 

433 corrected_first = _first_value_row(row_keys) 

434 grid_boundary = _first_value_row(grid_keys) 

435 if not corrected_first or not grid_boundary: 

436 return 

437 width = max((len(grow) for grow in cell_grid), default=0) 

438 stacks: List[Tuple[int, List[Tuple[int, GroundedCell]]]] = [] 

439 for col in range(width): 

440 frags = [ 

441 (gi, cell_grid[gi][col]) 

442 for gi in range(grid_boundary) 

443 if col < len(cell_grid[gi]) and _norm(cell_grid[gi][col].text) 

444 ] 

445 if frags: 

446 stacks.append((col, frags)) 

447 

448 def candidates(target: str) -> List[Tuple[int, int, List[Box]]]: 

449 out: List[Tuple[int, int, List[Box]]] = [] 

450 for col, frags in stacks: 

451 for i in range(len(frags)): 

452 acc = "" 

453 run: List[Box] = [] 

454 for j in range(i, len(frags)): 

455 acc += _norm(frags[j][1].text) 

456 b = frags[j][1].box 

457 if b is not None: 

458 run.append(b) 

459 if acc == target: 

460 out.append((frags[i][0], col, run)) 

461 break 

462 if len(acc) > len(target): 

463 break 

464 for gi, cell in frags: 

465 t = _norm(cell.text) 

466 if target != t and target in t and cell.box is not None: 

467 out.append((gi, col, [cell.box])) 

468 return sorted(out, key=lambda x: (x[0], x[1])) 

469 

470 targets: Dict[str, List[Tuple[int, int]]] = {} 

471 for r in range(corrected_first): 

472 for c, key in enumerate(row_keys[r]): 

473 if key: 

474 targets.setdefault(key, []).append((r, c)) 

475 for key, cells in targets.items(): 

476 if all((r, c) in boxes for r, c in cells): 

477 continue 

478 cands = candidates(key) 

479 if not cands: 

480 continue 

481 for i, (r, c) in enumerate(cells): 

482 if (r, c) in boxes: 

483 continue 

484 _, _, run = cands[min(i, len(cands) - 1)] 

485 if run: 

486 boxes[(r, c)] = _union(run) 

487 

488 

489def _close_unpaired_rows( 

490 pairs: Sequence[Tuple[int, int]], 

491 row_keys: Sequence[Sequence[str]], 

492 grid_keys: Sequence[Sequence[str]], 

493 cell_grid: Sequence[Sequence[GroundedCell]], 

494 boxes: Dict[Tuple[int, int], Box], 

495) -> None: 

496 """Box corrected rows that drew cells from a grid row the alignment never 

497 consumed. 

498 

499 When the agent rebuilds two stacked printed sections side by side, each 

500 corrected row holds one section's values in its left columns and the 

501 other's in its right — but a corrected row can align to only one grid row, 

502 so the other section's cells stay unboxed even though their grid row sits 

503 untouched. This pass offers each such corrected row the UNPAIRED grid rows, 

504 in order: it adopts the first one in which EVERY still-unboxed key (of two 

505 or more normalized chars) occurs as ordered non-overlapping spans, and 

506 each key takes the union of the grid-cell boxes its span touches. A pool 

507 row is consumed by exactly one corrected row, so twin sections cannot 

508 double-assign. All-or-nothing per row keeps the anchoring strong: a pool 

509 row must account for the row's whole remainder or none of it. 

510 """ 

511 paired = {g for _, g in pairs} 

512 pool = [g for g in range(len(cell_grid)) if g not in paired and any(grid_keys[g])] 

513 for r in range(len(row_keys)): 

514 missing = [c for c, key in enumerate(row_keys[r]) if key and (r, c) not in boxes and len(key) >= 2] 

515 if not missing: 

516 continue 

517 for pi, g in enumerate(pool): 

518 stream: List[Optional[Box]] = [] 

519 text = "" 

520 for cell, key in zip(cell_grid[g], grid_keys[g], strict=False): 

521 stream.extend([cell.box] * len(key)) 

522 text += key 

523 pos = 0 

524 found: Dict[int, Box] = {} 

525 for c in missing: 

526 key = row_keys[r][c] 

527 at = text.find(key, pos) 

528 if at < 0: 

529 break 

530 span = [b for b in stream[at : at + len(key)] if b is not None] 

531 if not span: 

532 break 

533 found[c] = _union(span) 

534 pos = at + len(key) 

535 if len(found) == len(missing): 

536 boxes.update({(r, c): b for c, b in found.items()}) 

537 pool.pop(pi) 

538 break 

539 

540 

541def _close_marker_labels( 

542 pairs: Sequence[Tuple[int, int]], 

543 row_keys: Sequence[Sequence[str]], 

544 grid_keys: Sequence[Sequence[str]], 

545 cell_grid: Sequence[Sequence[GroundedCell]], 

546 boxes: Dict[Tuple[int, int], Box], 

547 footnote_refs: Sequence[str], 

548) -> None: 

549 """Box row labels the correction extended with a catalogued footnote marker. 

550 

551 A superscript marker is printed on the page but often absent from both the 

552 Camelot grid and the text layer, so the corrected label ('Transaction and 

553 integration costs(1)') has no exact home in the grid even though the label 

554 itself does. Only markers the correction itself catalogued in 

555 `footnote_refs` are considered — stripping is grounded in the correction's 

556 own report, never a guess. For each still-unboxed cell in an aligned row 

557 pair, the key with a trailing catalogued marker removed must match a grid 

558 cell (or run) of the paired row exactly; the remainder must keep at least 

559 two normalized chars, so a bare parenthesized value like '(84)' can never 

560 be consumed as marker plus empty label. 

561 """ 

562 inners = {_norm(m).strip("()") for m in footnote_refs} 

563 suffixes = sorted( 

564 {f"({i})" for i in inners if i} | {i for i in inners if i and not any(ch.isalnum() for ch in i)}, 

565 key=len, 

566 reverse=True, 

567 ) 

568 if not suffixes: 

569 return 

570 for r, g in pairs: 

571 grow = cell_grid[g] 

572 gkeys = grid_keys[g] 

573 for c, key in enumerate(row_keys[r]): 

574 if not key or (r, c) in boxes: 

575 continue 

576 stripped = next( 

577 (key[: -len(s)] for s in suffixes if key.endswith(s) and len(key) - len(s) >= 2), None 

578 ) 

579 if stripped is None: 

580 continue 

581 found = _consume_run(grow, gkeys, 0, stripped) 

582 if found is not None and found[1] is not None: 

583 boxes[(r, c)] = found[1] 

584 

585 

586def locate_markers( 

587 marks: Sequence[FootnoteMark], 

588 footnote_refs: Sequence[str], 

589 markdown: str, 

590 footnotes: Sequence[FootnoteDef] = (), 

591 table_text: str = "", 

592) -> List[LocatedMarker]: 

593 """Resolve each quoted mark to its cell in the corrected markdown. 

594 

595 The agent QUOTES the carrying cell (`FootnoteMark.cell_text`); it never 

596 counts positions — the same contract as `CellMerge`, and for the same 

597 reason: counted coordinates are where the model errs. The lookup matches 

598 the quote against the corrected cells on exact normalized text, then with 

599 the marker's own group stripped from both sides — a superscript the text 

600 layer dropped leaves the quote and the cell differing only by the marker. 

601 Every cell the quote matches becomes a located entry (a marker printed on 

602 twin labels carries on both). A catalogued marker with no cell match is 

603 then tried against `table_text` (the table's title and subtitle): carried 

604 there, it places at table scope — it qualifies the whole table. What 

605 remains — every catalogued marker (in `footnote_refs` or the marks 

606 themselves) left without a position — is emitted once as an unplaced 

607 entry, kept and flagged downstream, never dropped. 

608 """ 

609 rows = markdown_rows(markdown) 

610 row_keys = [[_norm(v) for v in row] for row in rows] 

611 

612 def canon(marker: str) -> str: 

613 return _norm(marker).strip("()") 

614 

615 # Each distinct marker's printed form and the agent's judgement of what 

616 # it points at; a marker only listed in `footnote_refs` defaults to a 

617 # footnote. 

618 catalogued: Dict[str, Tuple[str, Literal["footnote", "section"]]] = {} 

619 for m in marks: 

620 c = canon(m.marker) 

621 if c: 

622 catalogued.setdefault(c, (m.marker, m.kind)) 

623 for printed in footnote_refs: 

624 c = canon(printed) 

625 if c: 

626 catalogued.setdefault(c, (printed, "footnote")) 

627 

628 def strip_marker(key: str, c: str) -> str: 

629 # The marker's printed forms, removed wherever they appear: the 

630 # parenthesized group for any marker, the bare glyph for a 

631 # pure-symbol marker ('*', '#') whose superscript carries no parens. 

632 out = key.replace(f"({c})", "") 

633 if not any(ch.isalnum() for ch in c): 

634 out = out.replace(c, "") 

635 return out 

636 

637 placed: List[LocatedMarker] = [] 

638 seen: set[Tuple[str, int, int]] = set() 

639 for m in marks: 

640 c = canon(m.marker) 

641 quote = _norm(m.cell_text) 

642 if not c or not quote: 

643 continue 

644 stripped_quote = strip_marker(quote, c) 

645 for r, row in enumerate(row_keys): 

646 for col, key in enumerate(row): 

647 if not key: 

648 continue 

649 if key == quote or (stripped_quote and strip_marker(key, c) == stripped_quote): 

650 pos = (m.marker, r, col) 

651 if pos not in seen: 

652 seen.add(pos) 

653 placed.append(LocatedMarker(marker=m.marker, row=r, col=col, kind=m.kind)) 

654 

655 # A marker printed AS a whole cell places itself: when a catalogued 

656 # marker or a captured definition's own marker is the entire content of 

657 # a table cell — a bare '#' (or '# %') standing in a variance column, a 

658 # bare '(b)' standing where the rate would print — that cell IS its 

659 # carrier: the table prints the marker in place of a value and the note 

660 # says why the value is absent, so no agent catalogue is needed to 

661 # connect them. An all-digits marker never places this way, because a 

662 # bare '(1)' cell is indistinguishable from a parenthesized negative 

663 # value; and an alphanumeric marker only matches its parenthesized form, 

664 # never the bare letter, which could be a genuine one-letter cell. A 

665 # marker that appears in no whole cell keeps its unplaced/unreferenced 

666 # flag. 

667 self_placing = dict(catalogued) 

668 for d in footnotes: 

669 c = canon(d.marker) 

670 if c: 

671 self_placing.setdefault(c, (d.marker, "footnote")) 

672 already = {canon(p.marker) for p in placed} 

673 for c, (printed, kind) in self_placing.items(): 

674 if c in already or c.isdigit(): 

675 continue 

676 forms = {f"({c})"} if any(ch.isalnum() for ch in c) else {c, f"{c}%"} 

677 for r, row in enumerate(row_keys): 

678 for col, key in enumerate(row): 

679 if key in forms: 

680 catalogued.setdefault(c, (printed, kind)) 

681 pos = (printed, r, col) 

682 if pos not in seen: 

683 seen.add(pos) 

684 placed.append(LocatedMarker(marker=printed, row=r, col=col, kind=kind)) 

685 

686 # A marker carried by the table's own header text — a title suffix 

687 # ('Volume and Rate Analysis (a)') or a spanning band absorbed into the 

688 # subtitle ('Accounts Classified as a TDR (c)') — qualifies the whole 

689 # table: every record sits under that text, so the marker places at 

690 # table scope and reaches all of them rather than being flagged as 

691 # unplaced. Matched on the parenthesized form (or the bare glyph for a 

692 # pure-symbol marker), the same forms a cell match uses. 

693 header_key = _norm(table_text) 

694 if header_key: 

695 placed_canon = {canon(p.marker) for p in placed} 

696 for c, (printed, kind) in catalogued.items(): 

697 # Digit markers match here up to two digits — a title suffix 

698 # 'Key Financials(1)' is unambiguous, while a longer 

699 # parenthesized number is a year or a value, never a marker. 

700 if c in placed_canon or (c.isdigit() and len(c) > 2): 

701 continue 

702 found = f"({c})" in header_key if any(ch.isalnum() for ch in c) else c in header_key 

703 if found: 

704 placed.append(LocatedMarker(marker=printed, kind=kind, scope="table")) 

705 

706 placed_canon = {canon(p.marker) for p in placed} 

707 return placed + [ 

708 LocatedMarker(marker=printed, kind=kind) 

709 for c, (printed, kind) in catalogued.items() 

710 if c not in placed_canon 

711 ] 

712 

713 

714def _consume_run( 

715 grow: Sequence[GroundedCell], gkeys: Sequence[str], start: int, key: str 

716) -> Optional[Tuple[int, Optional[Box]]]: 

717 """Find `key` at or after `start` as one grid cell or a run of consecutive 

718 grid cells whose concatenated text equals it exactly. 

719 

720 The run covers the correction's unreported cell joins — a rejoined symbol 

721 ('$' + '168,663') or a label Camelot split across cells — with the same 

722 exactness as a single-cell match: the concatenation must equal the target, 

723 character for character. Blank cells inside a run contribute nothing. 

724 Returns (index after the run, union box of the run's boxed cells), or 

725 None when nothing at or after `start` matches. 

726 """ 

727 for i in range(start, len(grow)): 

728 acc = "" 

729 parts: List[Box] = [] 

730 for j in range(i, len(grow)): 

731 acc += gkeys[j] 

732 if gkeys[j]: 

733 b = grow[j].box 

734 if b is not None: 

735 parts.append(b) 

736 if acc == key: 

737 return j + 1, _union(parts) if parts else None 

738 if len(acc) > len(key): 

739 break 

740 return None 

741 

742 

743def _align_rows( 

744 row_keys: Sequence[Sequence[str]], grid_keys: Sequence[Sequence[str]] 

745) -> List[Tuple[int, int]]: 

746 """Order-preserving row alignment maximizing shared cell values. 

747 

748 Classic weighted longest-common-subsequence over rows: a corrected row may 

749 pair with a grid row only if the corrected row's text contains at least one 

750 of the grid row's values (containment, because the correction may have 

751 joined grid cells into one corrected cell), rows never cross, and the 

752 pairing with the greatest total contained values wins. Pairing is only a 

753 routing decision — the cell step still demands exact text equality — so a 

754 generous metric here can never produce a wrong box. Deterministic, no 

755 thresholds to tune. 

756 """ 

757 

758 def overlap(a: Sequence[str], b: Sequence[str]) -> int: 

759 joined = "".join(a) 

760 return sum(1 for k in b if k and k in joined) 

761 

762 n, m = len(row_keys), len(grid_keys) 

763 weight = [[overlap(row_keys[i], grid_keys[j]) for j in range(m)] for i in range(n)] 

764 dp = [[0] * (m + 1) for _ in range(n + 1)] 

765 for i in range(1, n + 1): 

766 for j in range(1, m + 1): 

767 best = max(dp[i - 1][j], dp[i][j - 1]) 

768 if weight[i - 1][j - 1] > 0: 

769 best = max(best, dp[i - 1][j - 1] + weight[i - 1][j - 1]) 

770 dp[i][j] = best 

771 

772 pairs: List[Tuple[int, int]] = [] 

773 i, j = n, m 

774 while i > 0 and j > 0: 

775 if weight[i - 1][j - 1] > 0 and dp[i][j] == dp[i - 1][j - 1] + weight[i - 1][j - 1]: 

776 pairs.append((i - 1, j - 1)) 

777 i, j = i - 1, j - 1 

778 elif dp[i - 1][j] >= dp[i][j - 1]: 

779 i -= 1 

780 else: 

781 j -= 1 

782 return list(reversed(pairs)) 

783 

784 

785def find_dropped_header_text( 

786 grid: Sequence[Sequence[GroundedCell]], 

787 cell_grid: Sequence[Sequence[GroundedCell]], 

788 absorbed: str, 

789 page: int, 

790) -> List[str]: 

791 """Return printed header-area text that appears in no corrected cell. 

792 

793 A flattened multi-level header must carry every level, but the correction 

794 model drops a group label ('Coverage Data' printed over two sub-headers) 

795 inconsistently from run to run, even when instructed not to — so a prompt 

796 cannot be relied on to close it. The check is deterministic: a cell in 

797 the grid's header region (rows above its first value row) counts as 

798 dropped when NONE of its words appears in any corrected cell or in the 

799 table's absorbed metadata (title, subtitle, units, footnotes). Word 

800 level, because Camelot glues side-by-side stacks into single cells whose 

801 whole text exists nowhere even when every word was carried. Cells whose 

802 longest word is under four chars are skipped — stub noise, not a level. 

803 

804 Each returned fragment becomes a table-level review flag (status 

805 'header_text_dropped') in the flags record — the review channel; the log 

806 line here is a debugging trace only. 

807 """ 

808 boundary = _first_value_row([[c.text for c in grow] for grow in cell_grid]) 

809 if not boundary: 

810 return [] 

811 seen = _norm("\x00".join(c.text for row in grid for c in row if c.text) + "\x00" + absorbed) 

812 dropped = [] 

813 for gi in range(boundary): 

814 for cell in cell_grid[gi]: 

815 tokens = [t for t in (_norm(p) for p in cell.text.split()) if len(t) >= 3] 

816 if not tokens or max(len(t) for t in tokens) < 4: 

817 continue 

818 if not any(t in seen for t in tokens): 

819 dropped.append(cell.text.strip()) 

820 if dropped: 

821 logger.debug( 

822 "page {}: text printed above the table's first value row reached no output cell " 

823 "(flagged for review as 'header_text_dropped'): {}", 

824 page, 

825 [t[:50] for t in dropped], 

826 ) 

827 return dropped 

828 

829 

830def log_ungrounded_cells(grid: Sequence[Sequence[GroundedCell]], page: int) -> None: 

831 """Debug trace of corrected cells that carry no measured box. 

832 

833 Every gap is already accounted for by its status: expected conditions 

834 (unlocated headers and labels, one-character symbols) are pass tier, and 

835 anything needing a person reaches the flags record — that is the action 

836 channel. This line exists so a debugging session can see all of a table's 

837 gaps in one place; it asks for no action. Empty cells have no printed 

838 mark and are not gaps. 

839 """ 

840 missing = [ 

841 (r, c, cell.text, cell.status or "unclassified") 

842 for r, row in enumerate(grid) 

843 for c, cell in enumerate(row) 

844 if cell.text.strip() and cell.box is None 

845 ] 

846 if missing: 

847 logger.debug( 

848 "page {}: {} output cell(s) carry no measured location (each accounted for by its status): {}", 

849 page, 

850 len(missing), 

851 [(r, c, t[:40], s) for r, c, t, s in missing], 

852 ) 

853 

854 

855def log_ungrounded(merged: Sequence[MergedCellBox], page: int) -> None: 

856 """Debug trace of merges whose reported source addresses did not all resolve. 

857 

858 The record carries the miss (a None per unresolved address and a 

859 partial/none state), and the affected cell is then classified and — when 

860 a person should look — flagged through the flags record. This line is the 

861 step-by-step trace for debugging merge resolution; it asks for no action. 

862 """ 

863 for m in merged: 

864 if m.grounded_by == "cell_address": 

865 continue 

866 missing = [a for a, b in zip(m.source_cells, m.source_boxes, strict=False) if b is None] 

867 logger.debug( 

868 "page {}: combined cell {!r} (row {}, col {}) not fully located (grounded '{}'); " 

869 "unresolved source cells: {}", 

870 page, 

871 m.result, 

872 m.row, 

873 m.col, 

874 m.grounded_by, 

875 missing if m.source_cells else "none reported", 

876 )