Skip to content

fix(146): never promote a data row to the table header - #149

Merged
r-uben merged 3 commits into
mainfrom
fix/146-data-row-promoted-to-header
Aug 12, 2026
Merged

fix(146): never promote a data row to the table header#149
r-uben merged 3 commits into
mainfrom
fix/146-data-row-promoted-to-header

Conversation

@r-uben

@r-uben r-uben commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes cause 1 of #146: _grid_to_markdown took grid[0] as the header unconditionally, so when the column-header band was absent from the grid the first data row became the schema — its values presented as column names, and the observation gone from the body.

Reference case is Nakamura & Steinsson (WP) p.13, Table I:

| 3M Treasury yield | 0.67 |  |  |
| --- | --- | --- | --- |
|  | (0.14) |  |  |

Every number survives, so a numeric-multiset check passes and nothing detects it. That is the dangerous part: a wrong schema plus one fewer observation, silently.

Promotion is now gated on a new _is_data_row. A row is data when it names an entity in column 0 and holds at least one value-shaped token. Data rows get an empty header row (markdown permits one) and stay in the body — lossless, and no invented schema.

Scope / non-goals

Cause 2 is not touched — the rowizer's region still excludes the header band above the data rows (y 129..374 vs. a header at y 112..120). That is the same class of boundary error as GH-145 and wants its own design pass. #146 stays open for it; this PR is deliberately not Closes.

Commits

  1. 02c0ed5 — gate header promotion on _is_data_row.
  2. 7aaf992 — treat decorated values as values (review finding).
  3. a6a2b9d — let header_repair assert the header it just rebuilt (review finding).

Implementation notes

Not _is_header_row. The issue proposed calling the existing _is_header_row(grid[0]). That is not safe: it returns False for ['Firm', 'Nominal', 'Real'], a perfectly ordinary header, so wiring it in directly would demote real headers and breaks test_grid_to_markdown_shape. Its docstring scopes it to "safe to merge into the multi-line header prefix" — a conservative merge test, not a header test.

Decorated values (2). _NUM_TOKEN_RE is anchored, so 0.67*** does not match it — a starred coefficient row, the common shape in an econometrics table, was still being promoted. Value-shapedness now delegates to native_verifier.is_numeric_token, which already strips presentation before matching (GH-103): significance markers, markdown emphasis **23,126**, unicode minus −0.253, currency prefixes £43.2. Reuse, not a new regex. Imported inside the function because native_verifier imports _NUM_TOKEN_RE from reconstruct — module level would cycle.

The header_repair seam (3). An earlier revision of this description claimed header_repair was unaffected. That was wrong, and a reviewer caught it. header_repair.py:465 feeds the same _grid_to_markdown a header it has just reconstructed from native word geometry and gated on _header_is_faithful; inference then ran on top of that evidence and demoted any numeric-shaped band, discarding the repair and leaving the table with no schema at all — strictly worse than the original bug.

Reproduced end-to-end on synthetic CE-style geometry. A bare year band cannot reach that code (_is_table_header_row requires a range marker or connector word); the reachable case is a percentage-bin header that also prints its label-column name on the header line:

repaired[0] = ['Currency', '-23%', '-14%', '-5%', '+5%', '+14%', '+23%', '+30%']

Column 0 non-empty, every data cell a bare numeric token — exactly _is_data_row. Fixed with a keyword-only assume_header on _grid_to_markdown, passed only from the repair path. Evidence beats inference, but only where the evidence exists; the reconstruct path has none and keeps inferring.

Known residual ambiguity, documented in the docstring. On the inference path, a single-line header whose cells are themselves numeric (['Firm', '2024', '2025'], ['Variable', '(1)', '(2)']) is indistinguishable from data by shape alone and is treated as data — trading a lost column name for a guaranteed-present observation, the right direction for a citation corpus. Multi-line headers, where the band sits on its own row with an empty column 0, are unaffected: _is_header_row accepts those and _collapse_header_prefix merges them before this check runs.

Test plan — observed results

New tests/test_table_header_gh146.py, 17 tests:

  • _is_data_row — label+value, parenthesised SEs, %/thousands separators, significance markers, bold cells, U+2212, currency prefixes, word headers (incl. bold ones), column-metadata rows, label-only rows, single-column and empty rows.
  • _grid_to_markdown — empty-header arity, no value lost, real headers still promoted, pipe escaping in a demoted row.
  • assume_header — the flag at the unit seam, plus an end-to-end test driving the real repair_collapsed_header / repair_table_headers_on_page path on a synthetic fitz page. It asserts count == 1 and the exact repaired header, so it fails loudly if the fixture ever stops exercising the repair.

Observed:

  • ~/venvs/socr/bin/pytest -q1528 passed, 1 xfailed
  • ~/venvs/socr/bin/pytest tests/test_table_header_gh146.py tests/test_header_repair.py tests/test_reconstruct.py -q39 passed
  • uvx ruff@0.16.0 format --check .272 files already formatted

Reviewer notes

  • Two source files: src/socr/tables/reconstruct.py and the one call site in src/socr/tables/header_repair.py.
  • The interesting question is the assume_header boundary: callers with geometric evidence assert; callers without it infer. If a third caller appears, it must make that choice explicitly.
  • Independent of fix(145): stop a one-point region overlap from deleting a whole text block #148 (fix/145) — different files, and the tests live in their own file rather than extending test_region_overlap_gh145.py, so the two branches do not conflict.

_grid_to_markdown took grid[0] as the header unconditionally. When the
column-header band is absent from the grid, the first data row became the
schema: its values were presented as column names and the observation
vanished from the body. Every number survives, so a numeric-multiset check
passes and nothing detects it.

Gate the promotion on a new _is_data_row: a row is data when it names an
entity in column 0 and holds at least one value-shaped token. Data rows get
an empty header row and stay in the body -- lossless and schema-free.

Covers cause 1 of #146. The rowizer's region excluding the header band
(cause 2) is untouched.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c2e42f31-9249-4894-bfd6-d735b657ee19

📥 Commits

Reviewing files that changed from the base of the PR and between 02c0ed5 and a6a2b9d.

📒 Files selected for processing (3)
  • src/socr/tables/header_repair.py
  • src/socr/tables/reconstruct.py
  • tests/test_table_header_gh146.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/socr/tables/reconstruct.py

📝 Walkthrough

Walkthrough

The table reconstruction logic detects data-first rows, preserves them in Markdown output, and emits an empty header when no valid header exists. Header repair explicitly preserves reconstructed headers. Regression tests cover numeric formats, metadata rows, value retention, escaping, and PDF repair.

Changes

Headerless table reconstruction

Layer / File(s) Summary
Data-row classification
src/socr/tables/reconstruct.py, tests/test_table_header_gh146.py
_is_data_row identifies numeric observation values and excludes valid headers, metadata rows, label-only rows, and empty rows.
Markdown data preservation
src/socr/tables/reconstruct.py, tests/test_table_header_gh146.py
_grid_to_markdown retains data-first rows in the body, creates an empty header row, preserves values, and escapes pipes.
Header repair integration
src/socr/tables/header_repair.py, tests/test_table_header_gh146.py
Header repair uses assume_header=True for reconstructed headers. Tests cover numeric-shaped headers and end-to-end PDF repair.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing data rows from being promoted to table headers.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Prevent data rows from being promoted to Markdown table headers

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Add _is_data_row heuristic to detect label+numeric rows and block header promotion.
• Emit an empty Markdown header row when the first row is clearly data.
• Add regression tests covering GH-146 headerless-grid and escaping scenarios.
Diagram

graph TD
  A["Cleaned grid"] --> B["_grid_to_markdown"] --> C{"Row 0 is data?"}
  C --> D["Emit empty header"] --> E[/"Markdown table"/]
  C --> F["Promote row 0 header"] --> E

  subgraph Legend
    direction LR
    _p["Process"] ~~~ _d{"Decision"} ~~~ _o[/"Output"/]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Invert `_is_header_row` for promotion gating
  • ➕ Reuses an existing predicate; less new code surface.
  • _is_header_row is explicitly a conservative “safe-to-merge” test, not a general header detector.
  • ➖ Would misclassify legitimate single-line headers like ['Firm','Nominal','Real'] and break existing behavior/tests.
2. Header scoring heuristic (numeric fraction / entropy-based)
  • ➕ Could reduce ambiguity for cases like ['Firm','2024','2025'] by using table-wide context.
  • ➖ More complexity and tuning; higher risk of new false positives/negatives.
  • ➖ Harder to reason about than the current “lossless first” rule.

Recommendation: Keep the PR’s current approach: treat clearly label+value rows as data and fall back to an empty header for losslessness. This directly prevents silent observation loss (GH-146) while preserving normal header promotion for word-based headers and existing multi-line header collapsing behavior.

Files changed (2) +155 / -3

Bug fix (1) +36 / -3
reconstruct.pyGate header promotion with '_is_data_row' and emit empty header when needed +36/-3

Gate header promotion with '_is_data_row' and emit empty header when needed

• Introduces '_is_data_row', which classifies a row as data if it is not a merge-safe header row and contains at least one value-shaped token in data columns. Updates '_grid_to_markdown' to only promote 'grid[0]' to the Markdown header when it is not a data row; otherwise emits an empty header row and keeps all rows in the body to avoid silent observation loss (GH-146).

src/socr/tables/reconstruct.py

Tests (1) +119 / -0
test_table_header_gh146.pyAdd GH-146 regression tests for data-row header promotion +119/-0

Add GH-146 regression tests for data-row header promotion

• Adds targeted tests for '_is_data_row' across numeric formats (parentheses, percent, thousands separators) and common header shapes. Verifies '_grid_to_markdown' emits an empty header for headerless grids, preserves all values/rows, still promotes real headers, and escapes pipes when the first row is demoted to the body.

tests/test_table_header_gh146.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/socr/tables/reconstruct.py`:
- Around line 236-238: Update _is_data_row to return False immediately when row
is empty, before calling _is_header_row(row); preserve the existing header and
numeric-token checks for non-empty rows.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b8717d9-b7de-4e40-b5e9-5ea8c9a1c1ca

📥 Commits

Reviewing files that changed from the base of the PR and between a76d21f and 02c0ed5.

📒 Files selected for processing (2)
  • src/socr/tables/reconstruct.py
  • tests/test_table_header_gh146.py

Comment thread src/socr/tables/reconstruct.py Outdated
@qodo-code-review

qodo-code-review Bot commented Aug 11, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Decorated values not detected ✓ Resolved 🐞 Bug ≡ Correctness
Description
_is_data_row only recognizes values that match _NUM_TOKEN_RE exactly, so common value
presentations (e.g. trailing footnote markers like 0.67*, currency prefixes, unicode minus) won’t
be treated as values; when such a value appears in grid[0], _grid_to_markdown will still promote
that data row to the header and drop the first observation from the body.
Code

src/socr/tables/reconstruct.py[R236-239]

+    if _is_header_row(row):
+        return False
+    return any(_NUM_TOKEN_RE.match(c.strip()) for c in row[1:] if c.strip())
+
Evidence
The new gate hinges on _NUM_TOKEN_RE matching the entire stripped cell; _NUM_TOKEN_RE does not
allow common numeric adornments (e.g., trailing * or currency prefixes), and _clean_grid
preserves such characters. The repo already implements strip_presentation() to remove these
adornments before numeric checks elsewhere, indicating they are expected in extracted tokens and
should be accounted for in _is_data_row as well.

src/socr/tables/reconstruct.py[219-239]
src/socr/tables/reconstruct.py[68-71]
src/socr/tables/reconstruct.py[165-177]
src/socr/tables/native_verifier.py[440-457]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_is_data_row()` currently uses `_NUM_TOKEN_RE.match(c.strip())` on the whole cell string. This fails for “value-shaped” tokens that include common adornments (footnote markers like `*`, currency symbols, unicode minus), causing `_is_data_row(grid[0])` to return `False` and re-enabling the exact bug this PR is addressing (data row promoted to header, first observation lost).

### Issue Context
Elsewhere, the codebase already anticipates decorated numeric tokens and strips presentation markers before numeric classification.

### Fix Focus Areas
- src/socr/tables/reconstruct.py[219-239]
- src/socr/tables/reconstruct.py[68-71]
- src/socr/tables/reconstruct.py[165-177]
- src/socr/tables/native_verifier.py[440-457]

### Suggested fix
1. Add a small local normalization helper in `reconstruct.py` (to avoid import cycles) that:
  - trims whitespace
  - replaces unicode minus variants with `-`
  - strips common trailing footnote markers (at least `*`, `†`, `‡`) and optionally surrounding whitespace
  - strips common currency prefixes (optional, but consistent with existing handling)
  - optionally splits the cell on whitespace and checks each token
2. In `_is_data_row`, treat a row as “having a value” if **any token** in `row[1:]` is numeric after normalization (i.e., matches `_NUM_TOKEN_RE` and/or `_NUMERIC_RE.search`).
3. Add tests mirroring the GH-146 tests for first-row values like `0.67*`, `−0.67`, and `$0.67` to ensure such rows are demoted (empty header emitted) rather than promoted.

This keeps the gate conservative on headers (still requiring a value-shaped token) while making it robust to real-world numeric formatting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context
✅ Compliance rules (platform): 10 rules
Review mode: ⚖️ Balanced: This is a localized runtime behavior change in table reconstruction with meaningful schema/data-preservation edge cases; it merits a complete single-pass review, but only two focused edit sites do not justify extended redundancy.

Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/socr/tables/reconstruct.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/socr/tables/reconstruct.py">

<violation number="1" location="src/socr/tables/reconstruct.py:238">
P2: This new header-demotion path is shared by the header_repair path too: `header_repair.py:465` calls the same `_grid_to_markdown(repaired)` on the *repaired* header. Because `_is_data_row` returns True any time a non-empty col-0 row has at least one numeric-shaped data cell (e.g. a repaired/merged header like `['Firm', '2024', '2025']` or a header whose cells are percentage/stat tokens), the freshly repaired header would now be demoted to a body row and replaced with an empty header — silently discarding the repair that `repair_table_headers_in_text` just performed. The PR states header_repair remains compatible, but only its existing tests (which use word headers) exercise this; a numeric-shaped repaired header is not covered. Consider scoping the data-row demotion to the primary rowize path (e.g. a flag/separate renderer) or restricting it so a header produced by `_rebuild_header` is never demoted.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/socr/tables/reconstruct.py Outdated
Comment thread src/socr/tables/reconstruct.py Outdated
"""
if _is_header_row(row):
return False
return any(_NUM_TOKEN_RE.match(c.strip()) for c in row[1:] if c.strip())

@cubic-dev-ai cubic-dev-ai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This new header-demotion path is shared by the header_repair path too: header_repair.py:465 calls the same _grid_to_markdown(repaired) on the repaired header. Because _is_data_row returns True any time a non-empty col-0 row has at least one numeric-shaped data cell (e.g. a repaired/merged header like ['Firm', '2024', '2025'] or a header whose cells are percentage/stat tokens), the freshly repaired header would now be demoted to a body row and replaced with an empty header — silently discarding the repair that repair_table_headers_in_text just performed. The PR states header_repair remains compatible, but only its existing tests (which use word headers) exercise this; a numeric-shaped repaired header is not covered. Consider scoping the data-row demotion to the primary rowize path (e.g. a flag/separate renderer) or restricting it so a header produced by _rebuild_header is never demoted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/socr/tables/reconstruct.py, line 238:

<comment>This new header-demotion path is shared by the header_repair path too: `header_repair.py:465` calls the same `_grid_to_markdown(repaired)` on the *repaired* header. Because `_is_data_row` returns True any time a non-empty col-0 row has at least one numeric-shaped data cell (e.g. a repaired/merged header like `['Firm', '2024', '2025']` or a header whose cells are percentage/stat tokens), the freshly repaired header would now be demoted to a body row and replaced with an empty header — silently discarding the repair that `repair_table_headers_in_text` just performed. The PR states header_repair remains compatible, but only its existing tests (which use word headers) exercise this; a numeric-shaped repaired header is not covered. Consider scoping the data-row demotion to the primary rowize path (e.g. a flag/separate renderer) or restricting it so a header produced by `_rebuild_header` is never demoted.</comment>

<file context>
@@ -216,13 +216,46 @@ def _looks_tabular(grid: list[list[str]]) -> bool:
+    """
+    if _is_header_row(row):
+        return False
+    return any(_NUM_TOKEN_RE.match(c.strip()) for c in row[1:] if c.strip())
+
+
</file context>
Fix with cubic

r-uben added 2 commits August 11, 2026 18:25
Review finding on #149: `_NUM_TOKEN_RE` is anchored, so `0.67***` does not
match it. A starred coefficient row -- the common shape in an econometrics
table -- was therefore still classified as a header and promoted, which is
the exact defect this branch exists to close.

Delegate value-shapedness to `native_verifier.is_numeric_token`, which
already strips presentation before matching (GH-103): significance markers,
markdown emphasis, unicode minus and currency prefixes. Imported inside the
function because native_verifier imports `_NUM_TOKEN_RE` from this module.

Also pins the empty-row case, which `_is_header_row` already guards.
Second review finding on #149: `header_repair.py` calls the same
`_grid_to_markdown` on a header it has just reconstructed from native word
geometry and gated on `_header_is_faithful`. Header inference then ran on
top of that evidence and demoted any numeric-shaped band, discarding the
repair and leaving the table with no schema at all.

Reproduced end-to-end on synthetic CE-style geometry: a percentage-bin
header that also prints its label-column name (`Currency`) yields
`['Currency', '-23%', ...]`, which is `_is_data_row` by shape.

Add a keyword-only `assume_header` to `_grid_to_markdown` and pass it from
the repair path. Evidence beats inference, but only where the evidence
actually exists -- the reconstruct path has none and keeps inferring.
@r-uben
r-uben merged commit e1d5d91 into main Aug 12, 2026
5 checks passed
r-uben added a commit that referenced this pull request Aug 12, 2026
Review finding on #149: `_NUM_TOKEN_RE` is anchored, so `0.67***` does not
match it. A starred coefficient row -- the common shape in an econometrics
table -- was therefore still classified as a header and promoted, which is
the exact defect this branch exists to close.

Delegate value-shapedness to `native_verifier.is_numeric_token`, which
already strips presentation before matching (GH-103): significance markers,
markdown emphasis, unicode minus and currency prefixes. Imported inside the
function because native_verifier imports `_NUM_TOKEN_RE` from this module.

Also pins the empty-row case, which `_is_header_row` already guards.
@r-uben
r-uben deleted the fix/146-data-row-promoted-to-header branch August 12, 2026 11:57
r-uben added a commit that referenced this pull request Aug 12, 2026
PR #149 fixed only cause 1 of #146 — a data row promoted to header — and
deliberately left the issue open for cause 2: the rowizer's region excludes the
column-header band above the data rows (NS p13, region y 129..374 vs a header at
y 112..120). No plan covered that residual, so the table now ships an empty
header instead of a wrong one: lossless, still schema-less.

Add TICKET-A2b in the GH-144 folder rather than a new one — it is the same class
of reconstruct.py boundary error and must serialize on the same file, which only
one folder can express. A2 and A2b are one lane held by one agent in sequence;
waves bound concurrency, not how much work an agent does on a file it owns.

A2b does not name its own cause. A ~9 pt gap is below _SPLIT_GAP_MIN_PT (10.0),
so the split threshold is unlikely to be it; GH-144 A1 now also measures region
y-extent against the header band and must name the code path before A2b fixes
it. Critical path grows to six: A1 - A2 - A2b - 152 A1 - 152 A2 - 152 B1.

Wave 0 recorded DONE: PR #148 and PR #149 merged 2026-08-12.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant