A technical reference describing how Quber locates, extracts, corrects, and grounds financial tables — the marking, image-examination, and error-correction steps end to end — with the research lineage and project history that produced it.
Quber extracts tables from PDF documents — principally dense financial filings such as SEC exhibits and investor fact-books — and renders them as faithful, machine-readable Markdown. The hard part is not parsing a clean table; it is reliably answering two questions on a crowded page: where is each table, and what exactly does it contain, with a guarantee that no number was invented and no row silently dropped.
The Set-of-Mark design is Quber’s answer. It splits the job across three components, each trusted only for what it does well:
| Component | Responsible for | Why it is trusted for this |
|---|---|---|
| The vision model, over a marked image | Table identity and region | A multi-modal model reads printed labels reliably; it cannot be trusted to emit accurate continuous coordinates. |
| Camelot, over the PDF text layer | The values inside a region | Camelot reads the document’s real characters, not pixels — it never misreads a digit and never loses an interior row. |
| The vision model, over a cropped image | Table structure (columns, headers) | The picture is the ground truth for how columns split and how headers nest; the LLM re-arranges values to match it but may never read a value off it. |
One job each — vision places and shapes each table; the text layer holds every value
Blue is judged by the vision model; the two blue steps do different jobs — where vs shape — and neither ever reads a value. Green is the document’s own characters — the page text layer that Camelot parses — the single source of every number.
The phrase that recurs throughout the code captures the whole philosophy:
This document follows a single page from ingestion to final Markdown, pausing at every step where an image is examined and at every step where an error is caught and corrected. It closes with the research lineage behind the technique and the Jira history (epics, stories, spikes, and the open defects) that produced it.
The codebase cites exactly two papers, both in the module header of the grid locator (src/quber/agents/grid_locator.py, lines 16–27). They form a single line of work on visual prompting: instead of asking a model to predict where something is, you mark the image and ask the model to read your marks.
grid_locator.py:18–21.The paper overlays labelled marks (numbers, boxes, masks) on an image so the model answers a spatial question by reading the marks rather than predicting raw coordinates. This converts a hard regression problem (“give me the pixel box”) into an easy reading problem (“which marks does it cover?”), dramatically improving visual grounding.
grid_locator.py:22–24.A direct extension: overlay a labelled coordinate grid on the image as positional anchors the model reads. The scaffold gives the model a discrete, legible frame of reference, tightening the link between what it sees and the coordinates it reports.
Quber applies this line of work almost literally. The module docstring states the connection in the authors’ own terms:
The problem the papers solve is exactly the failure Quber measured first-hand. A vision model asked for a continuous bounding box “emits a discretized, often evenly-spaced grid that walks off the actual tables on repetitive / stacked layouts” — precisely the stacked, near-identical tables common in financial supplements. The marked grid sidesteps that failure mode by never asking for a coordinate at all.
Set-of-Mark extraction is implemented as a pydantic-graph with three steps. Page location fans out into one parallel branch per detected table; the branches rejoin and sort into reading order. The orchestrator lives in core/extractors/set_of_mark/orchestrator.py; the graph in set_of_mark/pipeline.py.
There are two distinct image-examination steps and several layered error-correction steps. Stage 2 examines the whole page (with a grid) to find tables; Stage 4 examines a cropped table to fix its structure. The grounding guard (Stage 4) and the completeness layer (§8) are the error-correction mechanisms. Each is detailed below.
The entry point is SetOfMarkExtractor.extract_tables(source) (set_of_mark/orchestrator.py). Two artifacts are produced from the PDF up front and carried for the rest of the run on SetOfMarkDeps:
fitz.open()) supplies each page’s width and height in PDF points. These are needed to convert normalized boxes into the point frames Camelot and the text-layer reader expect.render_pages(), named page-0001.png, … in a temporary directory. 200 DPI is tuned so the default 36×12 grid resolves to roughly 22 pt per row and 51 pt per column on US-Letter.Pixels and text are kept separate from the very first step. The rendered PNG is used only for what vision does — locating tables and judging structure. The text layer, read later through PyMuPDF and by Camelot, is the only source of character-accurate values. The two never cross.
Localization is the step the design is named for. The owner is PydanticAIGridLocator (src/quber/agents/grid_locator.py), invoked once per page inside the locate_tables graph step (page-concurrent, bounded by a semaphore).
overlay_grid() draws a numbered/lettered grid onto the rendered page: by default 36 rows (numbered 1…36, printed on both side margins) and 12 columns (lettered A…L, printed on the top and bottom margins), with thin gridlines — blue for rows, orange for columns. Labels are repeated on opposite margins so a label is always near whatever the model is looking at.
The actual marks the model sees. A real Quber render: page 1 of a Benchmark Electronics filing under overlay_grid() — rows 1–36 numbered on both side margins, columns A–L lettered top and bottom. The model answers “which rows/columns does each table cover?” by reading these printed labels, not by predicting coordinates.
The gridded PNG is sent to the model with a system prompt (build_prompt()) that defines what a table is, insists on bounding the whole table anatomy, and — critically — tells the model to read the printed labels rather than estimate coordinates:
The model returns a structured GridFlagResult — a list of tables, each an ordinal, a title, and a discrete grid-cell range. No floats. Because the answer is a set of marks, the model is doing the thing it is reliably good at (reading printed text), and the burden of turning marks into geometry falls on Quber’s own deterministic code.
The feared failure — a non-deterministic table drop on a stacked layout — did not reproduce under this scheme. Grid discretization plus a low-temperature model gives a table-count stability of 1.00 page-to-page in the validating spike, against a raw continuous-coordinate detector that drifted, produced false positives, and flipped table counts (macro IoU 0.42 vs 0.70 for the anatomy-prompted grid).
The coarse grid range is then refined by a deterministic geometry chain. This is the first place the system corrects for the imprecision of a coarse mark:
| Step | Function | What it does |
|---|---|---|
| 1. Grid → box | grid_region_norm() | Maps the 1-based row range and column letters to a normalized 0–1 box. The grid is ours, so the mapping is exact. |
| 2. Tighten | tighten_region() | Snaps the coarse cell span to the page’s actual text layer, pulling the box in to the real word boundaries. If a region has no text (an image-only chart), the coarse region is kept — a region is never dropped. |
| 3. Pad | pad_boxes() (BOX_PAD_PTS = 6.0) | Grows each box by a small fixed margin so a downstream crop cannot clip an edge. |
| 4. Declash | declash_stacked() | Pulls the bottom of a stacked table off the top of the next, so no box contains a neighbour’s content — the essential guard for back-to-back tables. |
The spike settled on declash + 6 pt pad as the finishing combination. Declash is essential to stop a stacked-table box bleeding into a neighbour’s title. A larger 12 pt pad crowded prose. Pure text-snapping alone was fragile. The output is a LocatedTable carrying the ordinal, title, normalized region, grid extents, and a tightened flag.
The boundary boxes that come out of Stage 2. The same page after grid IDs were converted, tightened to the text layer, padded, and de-clashed. Each red box bounds a whole table anatomy — title, multi-row header, body, and footnote — and the two stacked tables are cleanly separated. These regions are what constrain Camelot in Stage 3.
Each located table fans out into its own parallel branch (capture_table). The branch first converts the normalized region into Camelot’s table_areas string (norm_bbox_to_table_area()) and runs a single, region-constrained Camelot stream pass inside just that box (camelot_targeted()):
Constraining Camelot to the vision-identified box is what inverts the older pipeline: vision has already decided how many tables there are and where each one is, so Camelot only has to read cells within a known boundary. The result is a CamelotCandidate whose cells field — a grid of strings read from the PDF’s text layer — is the authoritative source of every value downstream.
When camelot_targeted returns None (a region with no extractable grid — e.g. an image-only chart), the branch does not discard the table. It emits an ExtractedTable with the visual identity intact (title and som_region) and an empty body. A vision-found table is never silently dropped — the standing financial-liability rule applied in code.
Camelot reads values correctly but often gets the structure wrong: it splits a currency symbol into its own column, fragments a multi-row header, or leaves a phantom empty column from stream over-segmentation. The second image-examination step fixes structure — and only structure. The owner is correct_structure() (core/extractors/camelot/correspondence/correction.py).
Three artifacts, all scoped to this one table, are assembled:
CAPTION_PAD_PTS = 28 points above the data box to capture the title/caption that sits just over the grid. Only the image is widened; the value text slice stays tight to the data box, so a taller crop cannot leak a neighbour’s numbers.
The arbiter image for one table (“Summary GAAP Items”) — the exact crop handed to the model, reaching 28 pt above the data box to capture the caption. The LLM reads the shape here (one spanning header “Three Months Ended” over three period columns); it is forbidden from reading any number off it.
region_text_in_bbox) — the authoritative source for any value, including rows Camelot may have truncated.Everything is scoped to the table’s bbox — crop and region text — so an adjacent table can never bleed in. Region-scoping prevents a specific failure seen on Visa page 1, where an adjacent table’s “Dec/Sep” values overwrote this table’s (see §11).
The system prompt (VET_STRUCTURE_PROMPT in agents/llm_client.py) names the image the arbiter of structure and the text the source of values, enumerates the structural errors to fix, and lays down a non-negotiable constraint on values:
This is the heart of the “structure ← image, values ← text” split. The model looks at the picture to decide shape; it is forbidden from looking at the picture to read a number. If the image shows rows Camelot missed, the model recovers them from the text layer, not from the pixels.
A prompt instruction is not a guarantee. The code enforces the no-invented-number rule deterministically after the model responds. This grounding guard is the single most important error-correction step in the system:
If the corrected table contains any figure that is not in the page text layer — the exact characters Camelot reads — the entire correction is thrown away and the deterministic grid render is emitted instead (llm_corrected = False). A hallucinated number can never reach the output. (Camelot’s extracted grid is a subset of that same text layer; the guard unions both, but the text layer is the underlying source.)
The grounding guard as control flow: a single ungrounded figure rejects the entire correction.
The comparison is by presence of the figure, not its exact rendering. numeric_keys() strips currency, percent, parentheses, commas and spacing, so $ 8,273.04, 8273.04 and 8,273.04% all key to the same 8273.04. This is deliberate: it lets the model legitimately move a currency symbol out of its own column or fold a percent back into a cell without tripping the guard. A value is rejected only when the figure itself is absent from the sources — never merely because a symbol moved.
An earlier design used an exact-literal drift guard (compare corrected cells against Camelot cell-for-cell). A validation sweep proved that guard wrong: on Visa Q1FY25 p4 the model correctly recovered a truncated row (240,083) that was present in the page text layer but absent from Camelot’s grid — an exact-vs-Camelot check would have rejected a correct fix. Presence grounding against the page text layer — the exact characters Camelot reads — is the replacement.
The two figures below are real output captured from running the live extractor on a Benchmark Electronics filing. Each shows three things side by side: the source table as it appears in the PDF, the raw Camelot grid the region-constrained pass produces, and the corrected Markdown after the image-arbiter pass — with the grounding guard confirming every figure already existed in the page text layer (what Camelot reads). Notice what Camelot gets wrong, and what the correction fixes:

| Three Months Ended June 30, 2024 | Three Months Ended March 31, 2025 | Three Months Ended June 30, 2025 | |
|---|---|---|---|
| Revenue | $ 666 | $ 632 | $ 642 |
| Gross Margin | 10.2% | 10.0% | 10.1% |
| Operating Margin | 4.1% | 1.9% | 3.2% |
| Diluted EPS | $ 0.43 | $ 0.10 | $ 0.03 |

| June 30, 2024 | March 31, 2025 | June 30, 2025 | |
|---|---|---|---|
| Days in accounts receivable | 51 | 53 | 52 |
| Days in contract asset | 25 | 25 | 25 |
| Days in inventory | 90 | 89 | 83 |
| Days in accounts payable | (52) | (61) | (55) |
| Days in advance payments from customers | (24) | (20) | (20) |
| Days in cash conversion cycle | 90 | 86 | 85 |
In both cases llm_corrected=True: the correction differed from the deterministic grid render, and every numeric value survived the grounding guard. The structure was re-arranged to match the picture; not a single number was read off the image, invented, or changed.
Beyond the grounding guard, Quber carries a deterministic completeness-and-repair toolkit in src/quber/agents/completeness.py. It was built during the Camelot rebuild to answer one question: was the whole table captured, or did part of it get cut off at an edge? — and to rebuild the missing edge rows from the text layer when so. These are the text-driven error-correction steps that complement the two image-examination steps.
The active Set-of-Mark capture path bounds the whole table anatomy up front (Stage 2’s anatomy prompt + tighten/pad), which largely removes edge-truncation at the source. The completeness machinery below is the deterministic backstop developed alongside the correspondence rebuild; it embodies the same “compare real text-layer tokens against real output tokens” principle the grounding guard uses, and is the canonical reference for how Quber detects and repairs truncation.
The key insight (CompletenessAuditor docstring): because Camelot reads the text layer, not pixels, it never drops a row from the middle of a table. The only way an assembled table can be incomplete is truncation at an edge. So the audit is deterministic — no model, no network, no image — and reads the same text layer Camelot does:
VALUE_TOKEN_RE): a number must carry a thousands separator, a decimal, a percent, parentheses, or five-or-more digits. Bare 1–4 digit integers — years like 2024, days like 31, footnote markers — are excluded so a caption or footnote inside the rough region does not read as a dropped row.EDGE_BAND_PTS = 40 points of the captured span’s top or bottom edge. Figures deep inside the span are not drops (Camelot keeps interior rows); figures far outside belong to a neighbour. This edge band is a geometry guard that does not depend on a clean box separating two stacked, near-identical tables — exactly the Visa p1 case where box-based regions bleed.The auditor returns a CompletenessVerdict: complete, a human-readable gap description, a reason, and a list of MissingFigure records (each with its value, position, and which edge it sits past) — the input to the repair step.
When the audit reports missing edge figures, round_off_grid() rebuilds the truncated rows from the text layer and merges them back into the cell grid. It is deterministic and value-preserving by construction:
infer_columns); each missing figure is slotted into the grid column whose centre is nearest its x-position.row_label_at).For the legacy detector path, tabular_bands() + repair_box() relocate a bounding box that landed in whitespace onto the tabular text actually there. tabular_bands finds vertical bands of contiguous rows that each carry two-or-more value-like figures (real data rows, excluding prose and lone labels); repair_box snaps the box to the band with the most vertical overlap, fixing both a too-narrow box (clipped row labels) and a vertically offset box. If no tabular band overlaps or sits near the box, it returns None — a phantom box over whitespace or a decorative banner is dropped rather than trusted.
The unifying rule across all of these steps: when a gap cannot be repaired, it is reported, never ignored. A table the vision step found but Camelot could not extract is emitted with its identity and an empty body. A truncation that no leftover text can supply is flagged. This is the direct codification of the standing instruction that, for financial documents, silent omission is a liability with no defence — failures must be surfaced, never silently judged acceptable.
The per-table branches rejoin at a list-append join (with preferred_parent_fork='closest' to avoid a premature firing), and finalize sorts the captured tables by (page, ordinal) — restoring document reading order, which the downstream LLM inference and rendering depend on. Each emitted ExtractedTable carries both its content and a full provenance trail:
| Field group | Fields | Source |
|---|---|---|
| Content | title, subtitle, markdown, footnotes | Structure correction (or fallback grid render) |
| Camelot provenance | page, bbox, flavor, camelot_accuracy | Region-constrained Camelot pass |
| Set-of-Mark provenance | som_region (normalized 0–1 box) | The grid locator |
| Audit | llm_corrected (did correction differ from the grid render?) | Grounding guard outcome |
Every numeric value in the output is traceable to the page text layer (the characters Camelot reads), and each table records exactly how it was produced.
There is no single “Set-of-Mark” epic. The technique was discovered, validated, and adopted through a chain of tickets, almost all under Epic QUE-91 (Extraction & Validation). The sequence that produced it:
| Key | Type | Summary | Status |
|---|---|---|---|
| QUE-91 | Epic | Extraction & Validation (parent of nearly all of the below) | To Do |
| QUE-217 | Task | Evaluate docling extraction modes (10-config eval) | Done |
| QUE-218 | Story | Adopt tuned-financial docling config as canonical | Done |
| QUE-216 | Story | Surface docling stage errors as quality_flags | To Do |
| QUE-219 | Story | Enable GPU for the RapidOCR/onnxruntime stage | Done |
| QUE-230 | Task | Rebuild Camelot with LLM region correspondence (image=arbiter) | Done |
| QUE-233 | Task | Restructure presentation without altering Camelot values (grounded design) | Done |
| QUE-237 | Task | Rename TableMerger → TableUnifier (no behaviour change) | Done |
| QUE-241 | Story | Migrate pydantic-ai + stage pydantic-graph (became SoM umbrella) | Done |
| └ QUE-245 | Sub-task | The Set-of-Mark spike — reliable visual isolation | Done |
| └ QUE-246 | Sub-task | Spike: parallel visual-grid + Camelot as first-class inputs | Done |
| QUE-240 | Sub-task | Fix recovery misfire that drops a table (p21 Churn) | Closed |
| QUE-247 | Task | Column ID for shared-header stacked tables (deferred from QUE-246) | To Do |
| QUE-251 | Bug | extract_page_context silently returns no context | Done |
| QUE-244 | Task | Upgrade docling to 2.102.1 and validate (Epic QUE-1) | Done |
| QUE-250 | Task | Clean up pyright warnings in the test suite (Epic QUE-89) | Done |
QUE-217 — the evaluation that justified the program
A page-by-page evaluation of 10 docling configurations against the TMUS Q2 FY25 investor fact-book. It established the failures that justify the entire program: a silent legal-disclaimer drop on page 29 in all 10 runs; VLM pipelines disqualified (Granite-Docling transcribed “AT&T” as “ATAT” and hallucinated quarters; SmolDocling generation-collapsed on ~44% of the file); chart extraction unsafe (a mislabelled stacked bar risks a ~120× overstatement); picture-description hallucinating authoritative-looking captions. It selected tuned-financial as the best candidate and explicitly seeded QUE-216 and QUE-218.
QUE-230 & QUE-233 — image as arbiter, and the grounded design
QUE-230 rebuilt Camelot as the authoritative source around “the rasterized page as the arbiter,” recognizing lattice (high precision) and stream (high recall) as complementary rather than redundant. QUE-233 then proved the grounded design in a 31-table sweep across four real financial PDFs: the image arbitrates structure, Camelot ∪ text layer together hold every value, the grounding guard replaces the wrong exact-literal guard, and correction is region-scoped to prevent the Visa p1 “Dec/Sep clobbering.” Result: 31 tables, 0 hallucinations, 0 real losses.
QUE-245 — the spike that chose Set-of-Mark
The pivotal experiment. It compared three Camelot-independent isolation methods: raw continuous-coordinate vision (macro IoU 0.42, drift and count-flips), the existing grid “data-grid” prompt (IoU 0.66, count-stability 1.00), and a new grid “anatomy” prompt that bounds the full table anatomy — title, units, spanning headers, stub column, body, totals, footnotes (IoU 0.70, count-stability 1.00, worst title clip 13 pt). The feared non-deterministic table drop did not reproduce. The accepted recommendation: promote vision + grid (Set-of-Mark), anatomy prompt, low-temperature model, 36-row grid, finished with declash + 6 pt pad, from a last-resort escalation to a primary every-page pass.
QUE-246 — inverting the pipeline
The follow-on spike that made the grid a primary, parallel input alongside Camelot instead of a last-resort cleanup — the shape the current set_of_mark/pipeline.py implements. Because the grid is count-stable and accurate while the raw detector is the drift-prone component, leading with the grid makes boundary and count correct from the start and renders most of the old drift-repair and escalation scaffolding redundant. It deferred shared-header column labelling to QUE-247.
QUE-247 — shared-header stacked tables. When several sub-tables stack under one shared column-header band, each sub-table’s crop sits below the header, so structure correction sees no header and falls back to generic “Column 1–6” labels. The data is correct and aligned; only column identity (which period each number belongs to) is missing — unfixable by prompt alone because the header is physically absent from the crop. The proposed fix carries the shared header as column metadata onto sibling sub-tables.
QUE-216 — surface docling stage errors as quality_flags. Converts docling WARNING/ERROR log records into typed quality flags attached to the output, with an is_clean() gate (any flag → human review). Intended to ship before any production rollout of automated docling extraction; operationalizes the “surface failures explicitly” rule.
For completeness: QUE-251 (now fixed) was a silent degradation on the analyze path — extract_page_context always returned no context because it iterated docling items incorrectly and read a non-existent _page attribute. The bug confirmed that the Set-of-Mark extraction engine does not use that method, so table data integrity was unaffected; the impact was context-free LLM titles/descriptions only.
src/quber/agents/grid_locator.py — Set-of-Mark grid locator (marks, geometry, paper citations)src/quber/core/extractors/set_of_mark/orchestrator.py — ingestion, render, run the graphsrc/quber/core/extractors/set_of_mark/pipeline.py — the three-step pydantic-graphsrc/quber/core/extractors/camelot/correspondence/correction.py — grounded structure correction + grounding guardsrc/quber/agents/llm_client.py — VET_STRUCTURE_PROMPT and the correction schemasrc/quber/agents/completeness.py — completeness audit, round-off recovery, box repairsrc/quber/core/extractors/camelot/acquire.py / .../correspondence/recovery.py / .../geometry.py — render, region-constrained Camelot, frame conversionsdocs/EXTRACTION_PIPELINE.md — canonical tuned-financial configurationProject QUE. Epics: QUE-91 (Extraction & Validation), QUE-1 (Document Processing), QUE-89 (Development Infrastructure). Set-of-Mark lineage: QUE-217 → 218 → 230/233 → 241 (245, 246). Defect QUE-240 (p21 Churn), superseded by 245. Open: QUE-216, QUE-247.
— End of document —