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
« 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.
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.
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.
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.
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.
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"""
60from __future__ import annotations
62import re
63from typing import Dict, List, Literal, Optional, Sequence, Tuple
65from loguru import logger
67from quber.agents.llm_client import CellMerge, FootnoteDef, FootnoteMark
68from quber.core.extractors.base import AUTHORIZED_LABELS, GroundedCell, LocatedMarker, MergedCellBox
70Box = Tuple[float, float, float, float]
72_ADDRESS_RE = re.compile(r"^([A-Za-z]+)\s*(\d+)$")
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")
80def _norm(s: str) -> str:
81 return re.sub(r"\s+", "", s or "")
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
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 )
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
117def parse_address(address: str) -> Optional[Tuple[int, int]]:
118 """A printed spreadsheet address to 0-based (grid row, grid column).
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
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.
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.
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])
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
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
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
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.
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]
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]
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
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)
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
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.
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"
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.
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)
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.
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))
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]))
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)
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.
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
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.
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]
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.
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]
612 def canon(marker: str) -> str:
613 return _norm(marker).strip("()")
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"))
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
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))
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))
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"))
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 ]
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.
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
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.
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 """
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)
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
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))
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.
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.
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
830def log_ungrounded_cells(grid: Sequence[Sequence[GroundedCell]], page: int) -> None:
831 """Debug trace of corrected cells that carry no measured box.
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 )
855def log_ungrounded(merged: Sequence[MergedCellBox], page: int) -> None:
856 """Debug trace of merges whose reported source addresses did not all resolve.
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 )