mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
fix(ocr): unblock pairing framework merge
This commit is contained in:
parent
a2e578874b
commit
2e542dabd0
9 changed files with 4103 additions and 1582 deletions
|
|
@ -1,13 +1,13 @@
|
|||
# OCR-v2 Project Management Log
|
||||
|
||||
> **Branch:** `feat/figure-pipeline-vnext` | **Last Updated:** 2026-07-03
|
||||
> **Active work:** Figure pipeline vnext cutover completed in worktree. Wrapper now points to vnext by default. 346 tests pass. Next: merge branch back to `master`.
|
||||
> **Branch:** `feat/ocr-pairing-framework` | **Last Updated:** 2026-07-03
|
||||
> **Active work:** Generic OCR pairing framework extracted; figure and table now both run on the shared pairing core in worktree. Targeted merge-unblock verification passed: 357 tests green, touched-file `ruff check`/`ruff format --check` green, 6 runnable real-paper table fixtures validated. Next: merge branch back to `master`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive Summary
|
||||
|
||||
**Current state:** Figure pipeline vnext is now fully implemented (11 passes incl. accounting), cutover corpus reviewed on 5 papers with 0 consumed-asset regressions, and the public wrapper has been switched to vnext on this branch. 346 tests pass (`59` vnext + `258` figure + `29` render). Next: merge `feat/figure-pipeline-vnext` back to `master`.
|
||||
**Current state:** The OCR pairing framework branch is merge-ready. Figure and table pipelines now share `ocr_pairing_types.py` / `ocr_pairing_state.py` / `ocr_pairing_framework.py`, while domain-specific logic stays in `ocr_figure_domain.py` and `ocr_table_domain.py`. The last merge blockers were cleared by (1) moving figure-only rotation enrichment out of generic state into a figure-domain hook, (2) hardening table semantic parity validation across all runnable real-paper fixtures including `37LK5T97`, and (3) cleaning touched-file lint/format issues. Verification: 357 targeted tests passed. Next: merge `feat/ocr-pairing-framework` back to `master`.
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
|
@ -46,31 +46,25 @@ raw observations → structural signatures → stable anchors/families → zone
|
|||
|
||||
## 2. Current Status
|
||||
|
||||
### 2.1 Test Suite
|
||||
### 2.1 Test / Verification Status
|
||||
</br>
|
||||
| Suite | Result |
|
||||
|-------|--------|
|
||||
| Figure stack (figures + reader + containment + backmatter boundary) | **286 passed** ✅ |
|
||||
| Document + roles + render + gate + rebuild + state machine + trace | **330 passed, 0 failed** ✅ |
|
||||
| Author bio detection (ocr_bio) | **37 passed** ✅ |
|
||||
| Spec contracts | All passed ✅ |
|
||||
| Real-paper regressions | All passed ✅ |
|
||||
| **Total OCR tests** | **1018 passed, 275 skipped (fixture unavailable), 0 failed** ✅ |
|
||||
| Pairing framework cutover suites (`test_ocr_figures` + `test_ocr_rebuild` + `test_ocr_tables` + `test_ocr_pairing_framework` + `test_ocr_table_pairing_framework`) | **357 passed, 0 failed** ✅ |
|
||||
| Real-paper table parity fixtures | **6 runnable fixtures checked** ✅ |
|
||||
| Touched-file lint (`ruff check`) | **OK** ✅ |
|
||||
| Touched-file format (`ruff format --check`) | **OK** ✅ |
|
||||
</br>
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| Structural gate | Installed |
|
||||
| Role assignment (seed only) | ✅ |
|
||||
| Zone inference + fallback | ✅ |
|
||||
| Figure inventory | Global distance clustering — correct |
|
||||
| Backmatter boundary | ✅ Ref-anchored partition committed |
|
||||
| Pre-ref tail zone | ✅ Fixed |
|
||||
| Abstract detection | ✅ Fixed |
|
||||
| Frontmatter heading normalization | ✅ Fixed — no text matching |
|
||||
| non_body_insert clustering | ✅ Fixed — page-1 guard threshold |
|
||||
| Render (backmatter headings) | ✅ Correct heading rendering |
|
||||
| State machine | ✅ Accepts done_degraded as terminal |
|
||||
| Rebuild (span backfill skip) | ✅ Version match check fixed |
|
||||
| Author bio detection (Passes B+C) | ✅ P0: post-ref text-only bios as backmatter_body. P1: figure-residual portrait assets as author_bio_asset + figure_caption support |
|
||||
| Figure pipeline vnext | ✅ Default wrapper switched; 11-pass pipeline active; cutover corpus reviewed (5 papers, 0 regressions) |
|
||||
| Figure pipeline vnext | ✅ Shared pairing core + figure-only pre-enricher hook |
|
||||
| Table pipeline vnext | ✅ Shared pairing core + semantic parity validation |
|
||||
| Pairing framework core | ✅ `ocr_pairing_*` modules active for both figure and table |
|
||||
| Rebuild/orchestrator seam | ✅ Public wrappers unchanged; callers still use `build_figure_inventory(...)` / `build_table_inventory(...)` |
|
||||
| Cross-domain figure/table conflict resolution | ✅ Still external to pairing core |
|
||||
|
||||
### 2.3 Fix Status
|
||||
|
||||
|
|
@ -95,48 +89,40 @@ raw observations → structural signatures → stable anchors/families → zone
|
|||
| 18 | — | **Issue 2**: Demoted-caption figure inner-text leakage | Container bbox regions | Validated `_container_bbox` regions in `tag_figure_contained_text` via 3 helpers + containment-only integration | `0e4ecbc` |
|
||||
| 19 | — | **Issue 5**: Cross-column page_assets groups falsely accepted | Column-homogeneity gate | `_column_band_id` + rejection in `_is_safe_page_assets_group` | `4ab227e` |
|
||||
| 20 | — | **Issue 6**: Same asset consumed by figure AND table | Post-hoc arbitration | `resolve_media_asset_conflicts` resolves asymmetric cases; weak/weak stays in `ownership_conflicts` | `4ab227e` |
|
||||
| 21 | — | Pairing framework extraction + figure migration | Refactor | Extracted shared `ocr_pairing_*` core from figure vnext; preserved public seams and figure behavior | `6229f6c`, `7cfbb5f`, `32541cf` |
|
||||
| 22 | — | Table vnext on pairing framework + public cutover | Refactor/feature | Added `ocr_table_domain.py` + `ocr_table_passes.py`, preserved resolver-consumed fields, switched `build_table_inventory(...)` to vnext | `db01518`, `ea6a1f0`, `a9e68ac`, `a2e5788` |
|
||||
| 23 | 37LK5T97 + cutover corpus | Merge-unblock hardening | Moved figure-only rotation enrichment out of generic state, validated 6 runnable real-paper table fixtures semantically, fixed touched-file lint/format issues | working tree (pre-merge session) |
|
||||
#### P2#1a — Previous-page legend locator bridge (✅ Fixed `3f61f4a`)
|
||||
WV2FF4NV Fig 10: locator "See legend on previous page" on p16 not bridged to full legend (in rejected_legends, misclassified as body_paragraph)
|
||||
|
||||
#### Remaining P2
|
||||
#### Remaining P2 / P3
|
||||
|
||||
1. **Figure containment gaps** — `cluster_bbox` containment only runs on matched figures; composite figures with demoted caption upstream never enter containment. Inner-text detection misses `vision_footnote`/`paragraph_title` raw_labels.
|
||||
2. **Short "Table N" caption matching** — bare `"Table 1."` labels miss table_asset in ownership pipeline. Existing code partially handles it; residual cases remain.
|
||||
3. **Page-1 body paragraph OCR text extraction failure** (was: width check) — W33NLVJ2 right col Introduction block: OCR detected text region (raw_label=text) but extracted empty string. Pipeline previously mis-signaled as `unknown_structural`. Fixed: now `ocr_text_missing`.
|
||||
4. **Body text backfill overlap** — `backfill_missing_text_from_pdf` uses `get_text("words", clip=expanded)` returning words beyond block's bbox; can cause text duplication at render level.
|
||||
5. **2HEUD5P9 reference ordering** — ✅ Fixed by F12 (raw_label=reference_content signal)
|
||||
6. **Mixed-column tail page zone absorption** — ✅ Fixed: tighten year-period regex
|
||||
|
||||
### P3 (Boundary / Edge Cases)
|
||||
|
||||
1. **DW biography page mismatch** — pages 32-34 vs expectations 33-34.
|
||||
2. **AJR side-caption recovery** — deliberately deferred; not part of generic inventory refactor.
|
||||
3. **Chinese Windows encoding** — non-ASCII PDF filenames garbled via GBK codepage. Glob fallback added; residual `meta.json` corruption from earlier runs.
|
||||
4. **`backfill_missing_text_from_pdf` bbox-exact filtering** — need decision on render-level dedup vs backfill-level bbox clamp.
|
||||
5. **Multi-caption page column collapse** — `page_assets` group on multi-column pages merges separate figures in different columns.
|
||||
6. **Figure/table shared-consumption registry** — no shared consumed registry for ambiguous image-like blocks.
|
||||
7. **Short papers (<3 pages) health falsely red** — no headings/abstract in Letter/Editor formats.
|
||||
1. **Compatibility naming debt** — `figure_no`, `legend`, and `FigurePipelineState` remain backwards-compat names inside the shared pairing core. They are intentionally deferred cleanup, not release blockers.
|
||||
2. **37LK5T97 legacy consumption bug** — legacy table inventory drops caption `block_id=0` from `consumed_block_ids` because `if bid` treats `0` as falsy; vnext keeps `'0'`. Validation now treats this as a known legacy bug, not a vnext regression.
|
||||
3. **Figure containment gaps** — `cluster_bbox` containment only runs on matched figures; composite figures with demoted caption upstream still do not enter containment.
|
||||
4. **Short "Table N" caption matching** — bare `"Table 1."` labels still have residual weak cases outside the validated fixture set.
|
||||
5. **Chinese Windows encoding** — non-ASCII PDF filenames can still surface GBK path issues in older artifacts.
|
||||
6. **Shared consumed registry for ambiguous image-like blocks** — figure/table post-hoc arbitration exists, but there is still no first-class shared consumed registry for unresolved image-like assets.
|
||||
|
||||
## 4. Active Queue
|
||||
|
||||
1. ✅ P1 backmatter boundary (ref-anchored partition)
|
||||
2. ✅ Pre-ref tail zone fix (4KCHGV2Z)
|
||||
3. ✅ Gate 5 frontmatter fix series (24YKLTHQ)
|
||||
4. ✅ Stale trace-vs-expectation fixtures cleared (10 assertion updates)
|
||||
5. ✅ All stale test expectations reconciled (non_body_insert, caffard, legend_like, structural gate, render, state machine, rebuild, truth docs)
|
||||
6. ✅ P0 author bio detection (post_ref_bio_cleanup for reference_item)
|
||||
7. ✅ P1 author bio detection (residual_author_bio_pass + figure_caption support + tag_figure_contained_text protection)
|
||||
8. **NEXT: Merge `feat/figure-pipeline-vnext` → `master`**
|
||||
1. ✅ Pairing framework extraction complete
|
||||
2. ✅ Figure migration onto shared pairing core complete
|
||||
3. ✅ Table migration onto shared pairing core complete
|
||||
4. ✅ Generic-state impurity removed (`_match_pre_enricher` hook at figure boundary)
|
||||
5. ✅ Real-paper table semantic parity validation hardened (6 runnable fixtures)
|
||||
6. ✅ Touched-file lint / format cleanup complete
|
||||
7. **NEXT: Merge `feat/ocr-pairing-framework` → `master`**
|
||||
|
||||
### 4.1 Immediate Next Steps
|
||||
|
||||
- [x] Phase 0-4 vnext implementation complete
|
||||
- [x] Phase 5 cutover evaluation complete
|
||||
- [x] Cutover corpus: 5 papers, all 9 spec categories covered
|
||||
- [x] Cutover diff review: improvement=2, equivalent=2, parity=1, regression=0
|
||||
- [x] Wrapper switched to vnext in branch
|
||||
- [x] Full verification: 346 passed
|
||||
- [ ] Merge `feat/figure-pipeline-vnext` into `master`
|
||||
- [x] Shared `ocr_pairing_*` core active for figure + table
|
||||
- [x] Public wrappers preserved
|
||||
- [x] Table parity checks upgraded from smoke shape to semantic comparison
|
||||
- [x] `37LK5T97` made runnable under `tests/fixtures/ocr_vnext_real_papers/`
|
||||
- [x] Generic state made domain-neutral for match enrichment
|
||||
- [x] Targeted verification: 357 passed
|
||||
- [ ] Merge `feat/ocr-pairing-framework` into `master`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -236,6 +222,9 @@ WV2FF4NV Fig 10: locator "See legend on previous page" on p16 not bridged to ful
|
|||
| 2026-07-01 | Broaden recovery gate to handle normal prematch unknown figures | Synthetic-figure gate (`bbox_only_asset` flag) excluded `figure_unknown_NNN` from normal rotated prematch path. Gate now allows figure_unknown figures without synthetic flags. |
|
||||
| 2026-07-03 | Cutover uses evidence gates, not code confidence | VNext matched or improved on the full cutover corpus with identical consumed asset sets; wrapper switch became a release decision only after diff review + gate verification. |
|
||||
| 2026-07-03 | Legacy schema tests must be upgraded before wrapper switch | Real-paper behavior was cutover-ready earlier, but `test_ocr_figures.py` still asserted legacy-only inventory keys. Updating the test contract was required to make wrapper switch honest. |
|
||||
| 2026-07-03 | Generic state uses a domain hook for figure rotation enrichment | Figure rotation metadata had to stay behaviorally intact without polluting the shared pairing state used by table passes. A pre-match enricher hook keeps the core generic and the figure path exact. |
|
||||
| 2026-07-03 | Table parity comparison normalizes benign storage drift only | int/str block IDs, ordering differences, and `None` vs empty unmatched asset IDs are storage-level drift already tolerated downstream. Validation now compares semantic fields rather than raw serialization artifacts. |
|
||||
| 2026-07-03 | Legacy `block_id=0` drop is treated as a legacy bug, not a parity target | `37LK5T97` showed legacy dropping consumed caption id `0` via falsy filtering. The branch keeps vnext truth and documents the legacy defect instead of reproducing it. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -327,6 +316,7 @@ python -m ruff check paperforge/worker/ocr_*.py
|
|||
| 2026-07-02 | Round 2 truth audit + 3 targeted bug fixes for 37LK5T97 | Batch-audited 10 fresh papers (5 GREEN / 4 YELLOW / 1 RED). 37LK5T97 found with Figure 1 broken (sidecar caption demoted) + 6 unmatched rotated tables. Fixes: (1) `_is_sidecar_candidate` guard in candidate_resolution, (2) `adjacent_x`+`y_overlap` in score_table_match for rotated captions, (3) rotated table render bbox+270° correction. Also: rotated figure crop quality fix (4x zoom + coordinate normalization in `_crop_asset_from_pdf`). Commits: `59cd01a`, `bd3f3b6`, `86e0d14`. 428 regression tests pass. | §9.20 |
|
||||
| 2026-07-02 | Zone/role robustness completion — Figure caption prefix recovery + inline table fix + table matching audit | Figure caption prefix recovery from PDF text layer (`_recover_figure_heading_prefix`): 5S7UI34M 4→9 matched figures, 33→1 unmatched. Inline `<table>` HTML role fix: 650 blocks now `table_html` after rebuild (priority bug: raw_label=table fired before `<table>` check). Table matching audit: 620 remaining `media_asset` pending full rebuild. 585 figure/table/role tests pass. | §9.21 |
|
||||
| 2026-07-03 | Figure pipeline vnext cutover completed | Implemented all remaining vnext passes (composite parent, group/classic sequential, unresolved consolidation, accounting), expanded compare harness, curated 5-paper cutover corpus covering all 9 spec categories, generated diff review (improvement=2 / equivalent=2 / parity=1 / regression=0), updated legacy figure tests for vnext contract, and switched `build_figure_inventory(...)` wrapper to vnext on branch `feat/figure-pipeline-vnext`. Verification: 346 tests passed. | §9.22 |
|
||||
| 2026-07-03 | OCR pairing framework merge-unblock pass | Cleared the remaining merge blockers on `feat/ocr-pairing-framework`: moved figure-only rotation enrichment out of generic state, upgraded table cutover validation to semantic parity across 6 runnable real-paper fixtures including `37LK5T97`, and cleaned touched-file lint/format issues. Verification: 357 targeted tests passed; merge-ready. | §9.23 |
|
||||
|
||||
## 9. Historical Detail Archive
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .ocr_pairing_types import ClaimProposal, OwnershipConflict, ResourceRef
|
||||
|
|
@ -28,13 +29,15 @@ class OwnershipLedger:
|
|||
attempted_owner=owner,
|
||||
reason=reason,
|
||||
)
|
||||
self._journal.append({
|
||||
"action": "conflict",
|
||||
"resource": asset,
|
||||
"current_owner": current,
|
||||
"attempted_owner": owner,
|
||||
"reason": reason,
|
||||
})
|
||||
self._journal.append(
|
||||
{
|
||||
"action": "conflict",
|
||||
"resource": asset,
|
||||
"current_owner": current,
|
||||
"attempted_owner": owner,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
return conflict
|
||||
for asset in assets:
|
||||
self._owners[asset] = owner
|
||||
|
|
@ -54,23 +57,21 @@ class OwnershipLedger:
|
|||
def can_claim_group(self, group: ResourceRef) -> bool:
|
||||
return group not in self._reserved_groups
|
||||
|
||||
def transition_reserved_group_to_claimed(
|
||||
self, group: ResourceRef, *, owner: ResourceRef, reason: str
|
||||
) -> None:
|
||||
def transition_reserved_group_to_claimed(self, group: ResourceRef, *, owner: ResourceRef, reason: str) -> None:
|
||||
self._reserved_groups.discard(group)
|
||||
self._journal.append({
|
||||
"action": "transition_to_claimed",
|
||||
"resource": group,
|
||||
"owner": owner,
|
||||
"reason": reason,
|
||||
})
|
||||
self._journal.append(
|
||||
{
|
||||
"action": "transition_to_claimed",
|
||||
"resource": group,
|
||||
"owner": owner,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
|
||||
def snapshot(self) -> list[dict[str, object]]:
|
||||
return list(self._journal)
|
||||
|
||||
def journal_text_attachment(
|
||||
self, texts: list[ResourceRef], *, owner: ResourceRef, reason: str
|
||||
) -> None:
|
||||
def journal_text_attachment(self, texts: list[ResourceRef], *, owner: ResourceRef, reason: str) -> None:
|
||||
for text in texts:
|
||||
self._journal.append(
|
||||
{
|
||||
|
|
@ -100,69 +101,46 @@ class FigurePipelineState:
|
|||
diagnostics: list[dict] = field(default_factory=list)
|
||||
reservations: list[dict] = field(default_factory=list)
|
||||
completeness: dict = field(default_factory=dict)
|
||||
# Hook set by domain code (e.g., figures) to enrich match_records before storing.
|
||||
_match_pre_enricher: Callable | None = None
|
||||
|
||||
def accept_match(self, proposal: ClaimProposal, match_record: dict) -> None:
|
||||
# Enrich match_record with rotation metadata if the legend has rotated text.
|
||||
self._enrich_rotation(proposal, match_record)
|
||||
if self._match_pre_enricher is not None:
|
||||
self._match_pre_enricher(proposal, match_record, self)
|
||||
self.matches.append(match_record)
|
||||
self.diagnostics.append({
|
||||
"event": "match_accepted",
|
||||
"pass_name": proposal.pass_name,
|
||||
"figure_no": proposal.figure_no,
|
||||
"reason": proposal.reason,
|
||||
"resources": {
|
||||
"legends": proposal.legends,
|
||||
"assets": proposal.assets,
|
||||
"groups": proposal.groups,
|
||||
},
|
||||
})
|
||||
|
||||
def _enrich_rotation(self, proposal: ClaimProposal, match_record: dict) -> None:
|
||||
"""Detect rotated caption and add rotation_correction_deg + cluster_bbox."""
|
||||
from .ocr_figures import _caption_has_rotated_text, _prepare_rotated_caption_normalization
|
||||
|
||||
if not self.corpus or not proposal.legends:
|
||||
return
|
||||
legend_ref = proposal.legends[0]
|
||||
# Find the legend block in the corpus
|
||||
legend_block = None
|
||||
for b in self.corpus.blocks:
|
||||
if str(b.get("block_id", "")) == str(legend_ref.block_id) and b.get("page") == legend_ref.page:
|
||||
legend_block = b
|
||||
break
|
||||
if legend_block is None or not _caption_has_rotated_text(legend_block):
|
||||
return
|
||||
# Find the first asset block from the proposal
|
||||
if not proposal.assets or not self.corpus.raw_assets:
|
||||
return
|
||||
asset_ref = proposal.assets[0]
|
||||
asset_block = None
|
||||
for a in self.corpus.raw_assets:
|
||||
if str(a.get("block_id", "")) == str(asset_ref.block_id) and a.get("page") == asset_ref.page:
|
||||
asset_block = a
|
||||
break
|
||||
if asset_block is None:
|
||||
return
|
||||
normalized = _prepare_rotated_caption_normalization(legend_block, asset_block)
|
||||
if normalized is not None:
|
||||
match_record["rotation_correction_deg"] = normalized["rotation_correction_deg"]
|
||||
match_record["cluster_bbox"] = normalized["rotation_union_bbox"]
|
||||
self.diagnostics.append(
|
||||
{
|
||||
"event": "match_accepted",
|
||||
"pass_name": proposal.pass_name,
|
||||
"figure_no": proposal.figure_no,
|
||||
"reason": proposal.reason,
|
||||
"resources": {
|
||||
"legends": proposal.legends,
|
||||
"assets": proposal.assets,
|
||||
"groups": proposal.groups,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def accept_reservation(self, proposal: ClaimProposal) -> None:
|
||||
self.reservations.append({
|
||||
"pass_name": proposal.pass_name,
|
||||
"figure_no": proposal.figure_no,
|
||||
"reason": proposal.reason,
|
||||
"legends": list(proposal.legends),
|
||||
"groups": list(proposal.groups),
|
||||
})
|
||||
self.diagnostics.append({
|
||||
"event": "reservation_accepted",
|
||||
"pass_name": proposal.pass_name,
|
||||
"figure_no": proposal.figure_no,
|
||||
"reason": proposal.reason,
|
||||
"resources": {"groups": proposal.groups},
|
||||
})
|
||||
self.reservations.append(
|
||||
{
|
||||
"pass_name": proposal.pass_name,
|
||||
"figure_no": proposal.figure_no,
|
||||
"reason": proposal.reason,
|
||||
"legends": list(proposal.legends),
|
||||
"groups": list(proposal.groups),
|
||||
}
|
||||
)
|
||||
self.diagnostics.append(
|
||||
{
|
||||
"event": "reservation_accepted",
|
||||
"pass_name": proposal.pass_name,
|
||||
"figure_no": proposal.figure_no,
|
||||
"reason": proposal.reason,
|
||||
"resources": {"groups": proposal.groups},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
PipelineState = FigurePipelineState
|
||||
|
|
|
|||
|
|
@ -1,27 +1,30 @@
|
|||
# OCR-v2 Active Queue
|
||||
> Status: ACTIVE QUEUE — Round 2 truth audit + 3 targeted bug fixes. 428 regression tests pass.
|
||||
> Last updated: 2026-07-02
|
||||
> Scope: post-audit cleanup
|
||||
> Status: ACTIVE QUEUE — OCR pairing framework branch is merge-ready after merge-unblock verification. 357 targeted tests pass.
|
||||
> Last updated: 2026-07-03
|
||||
> Scope: pairing framework integration / post-merge follow-up
|
||||
|
||||
## Current Priorities
|
||||
1. **Monitor production OCR** after rotated-figure prematch, asset-internal recovery, sidecar/table fixes
|
||||
2. **Archive stale `project/current/` files**
|
||||
3. **Group-first figure inventory refactor** (deferred)
|
||||
1. **Merge `feat/ocr-pairing-framework`** back to `master`
|
||||
2. **Monitor production OCR** after pairing-core unification (figure + table on shared framework)
|
||||
3. **Archive stale `project/current/` files**
|
||||
4. **Compatibility naming cleanup** (`figure_no` / `legend` / `FigurePipelineState`) — deferred
|
||||
|
||||
## Completed This Session (cumulative)
|
||||
- **Round 2 truth audit**: 10 new papers batch-audited via ocr_truth_audit.py (high-risk mode)
|
||||
- 5 GREEN / 4 YELLOW / 1 RED (37LK5T97 — Figure 1 broken, tables unmatched)
|
||||
- 2 vision agents dispatched for RED papers
|
||||
- Findings: `docs/superpowers/specs/2026-07-02-ocr-truth-audit-round2-findings.md`
|
||||
- **37LK5T97 Figure 1 sidecar fix** (ocr_document.py)
|
||||
- Add `_is_sidecar_candidate` guard in candidate_resolution demotion
|
||||
- Sidecar caption in adjacent column no longer demoted as narrative prose
|
||||
- Figure 1 matched: `figure_001`, asset=block_id=9
|
||||
- **37LK5T97 rotated table matching fix** (ocr_scores.py)
|
||||
- Add `adjacent_x` + `y_overlap_with_asset` checks in score_table_match for rotated captions
|
||||
- All 6 tables matched with has_asset=true
|
||||
- **37LK5T97 rotated table render fix** (ocr_tables.py, ocr_objects.py)
|
||||
- `_table_has_rotated_content` detects dir=[0,-1], computes union render_bbox + rotation_deg=270
|
||||
- Tables 1-5 rendered at correct orientation (1908×2858 → 2858×1908)
|
||||
- Same approach as rotated figure rendering
|
||||
- Git: commits `16f9403` (recovery), `59cd01a` (figure quality), `bd3f3b6` (sidecar+table match), `86e0d14` (table render)
|
||||
- **Pairing framework extraction**:
|
||||
- Shared `ocr_pairing_types.py`, `ocr_pairing_state.py`, `ocr_pairing_framework.py`
|
||||
- Figure pipeline migrated onto shared core without changing public seam
|
||||
- **Table pipeline migration**:
|
||||
- Added `ocr_table_domain.py` + ordered `ocr_table_passes.py`
|
||||
- `build_table_inventory(...)` now routes to table vnext on shared core
|
||||
- **Merge-unblock hardening**:
|
||||
- Generic-state figure rotation enrichment moved behind figure-only `_match_pre_enricher`
|
||||
- Table semantic parity validation upgraded across 6 runnable real-paper fixtures
|
||||
- Added runnable `tests/fixtures/ocr_vnext_real_papers/37LK5T97/blocks.structured.jsonl`
|
||||
- Touched-file `ruff check` / `ruff format --check` green
|
||||
- **Verification**:
|
||||
- `tests/test_ocr_figures.py`
|
||||
- `tests/test_ocr_rebuild.py`
|
||||
- `tests/test_ocr_tables.py`
|
||||
- `tests/test_ocr_pairing_framework.py`
|
||||
- `tests/test_ocr_table_pairing_framework.py`
|
||||
- Result: **357 passed, 0 failed**
|
||||
|
|
|
|||
|
|
@ -14,27 +14,57 @@ def _load_blocks(fixture_root: Path) -> list[dict]:
|
|||
candidate = fixture_root / "blocks.structured.jsonl"
|
||||
return [json.loads(line) for line in candidate.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _str_id(val: object) -> str | None:
|
||||
"""Normalize a block ID to str, or None if empty/missing."""
|
||||
if val is None or val == "":
|
||||
return None
|
||||
return str(val)
|
||||
|
||||
|
||||
def _str_ids_sorted(vals: list) -> list[str]:
|
||||
"""Normalize block ID list to sorted strings, dropping None/empty entries."""
|
||||
return sorted(str(v) for v in (vals or []) if v is not None and v != "")
|
||||
|
||||
|
||||
def _normalize_table(table: dict) -> dict[str, object]:
|
||||
"""Normalize a table inventory entry so legacy and vnext results are comparable.
|
||||
|
||||
Benign drift handled:
|
||||
- block IDs stored as int vs str -> normalized to str
|
||||
- ``asset_block_id`` stored as ``""`` (legacy) vs ``None`` (vnext) -> both become None
|
||||
- ordering of list fields -> sorted
|
||||
"""
|
||||
asset_id = _str_id(table.get("asset_block_id"))
|
||||
return {
|
||||
"caption_block_id": table.get("caption_block_id"),
|
||||
"caption_block_id": _str_id(table.get("caption_block_id")),
|
||||
"page": table.get("page"),
|
||||
"formal_table_number": table.get("formal_table_number"),
|
||||
"asset_block_id": table.get("asset_block_id"),
|
||||
"asset_block_id": asset_id, # None if empty in either pipeline
|
||||
"match_status": table.get("match_status"),
|
||||
"note_block_ids": list(table.get("note_block_ids") or []),
|
||||
"bridge_block_ids": list(table.get("bridge_block_ids") or []),
|
||||
"consumed_block_ids": list(table.get("consumed_block_ids") or []),
|
||||
"note_block_ids": _str_ids_sorted(table.get("note_block_ids", [])),
|
||||
"bridge_block_ids": _str_ids_sorted(table.get("bridge_block_ids", [])),
|
||||
"consumed_block_ids": _str_ids_sorted(table.get("consumed_block_ids", [])),
|
||||
"render_rotation_deg": table.get("render_rotation_deg", 0),
|
||||
}
|
||||
|
||||
|
||||
def _table_sort_key(t: dict) -> tuple:
|
||||
return (t.get("page") or 0, t.get("formal_table_number") or 0)
|
||||
|
||||
|
||||
def compare_table_inventory_legacy_vs_vnext(fixture_root: Path) -> dict[str, object]:
|
||||
blocks = _load_blocks(fixture_root)
|
||||
legacy = build_table_inventory_legacy(copy.deepcopy(blocks))
|
||||
vnext = build_table_inventory_vnext(copy.deepcopy(blocks))
|
||||
legacy_norm = [_normalize_table(t) for t in legacy.get("tables", [])]
|
||||
vnext_norm = [_normalize_table(t) for t in vnext.get("tables", [])]
|
||||
# Sort deterministically so ordering drift does not cause false diffs.
|
||||
legacy_norm.sort(key=_table_sort_key)
|
||||
vnext_norm.sort(key=_table_sort_key)
|
||||
return {
|
||||
"legacy_raw": legacy,
|
||||
"vnext_raw": vnext,
|
||||
"legacy": legacy_norm,
|
||||
"vnext": vnext_norm,
|
||||
"diff": {
|
||||
|
|
|
|||
292
tests/fixtures/ocr_vnext_real_papers/37LK5T97/blocks.structured.jsonl
vendored
Normal file
292
tests/fixtures/ocr_vnext_real_papers/37LK5T97/blocks.structured.jsonl
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -51,8 +51,8 @@ def test_pairing_types_module_exports_generic_types():
|
|||
|
||||
|
||||
def test_figure_vnext_types_module_reexports_framework_types():
|
||||
from paperforge.worker.ocr_pairing_types import ResourceRef as FrameworkResourceRef
|
||||
from paperforge.worker.ocr_figure_vnext_types import ResourceRef as FigureModuleResourceRef
|
||||
from paperforge.worker.ocr_pairing_types import ResourceRef as FrameworkResourceRef
|
||||
|
||||
assert FigureModuleResourceRef is FrameworkResourceRef
|
||||
|
||||
|
|
@ -97,8 +97,8 @@ def test_pairing_state_module_exports_ownership_ledger():
|
|||
|
||||
|
||||
def test_figure_vnext_state_module_reexports_framework_ledger():
|
||||
from paperforge.worker.ocr_pairing_state import OwnershipLedger as FrameworkLedger
|
||||
from paperforge.worker.ocr_figure_vnext_state import OwnershipLedger as FigureModuleLedger
|
||||
from paperforge.worker.ocr_pairing_state import OwnershipLedger as FrameworkLedger
|
||||
|
||||
assert FigureModuleLedger is FrameworkLedger
|
||||
|
||||
|
|
@ -115,12 +115,14 @@ def test_run_pairing_passes_executes_in_declared_order():
|
|||
|
||||
class FirstPass:
|
||||
name = "first"
|
||||
|
||||
def run(self, state):
|
||||
seen.append(self.name)
|
||||
return PassReport(pass_name=self.name)
|
||||
|
||||
class SecondPass:
|
||||
name = "second"
|
||||
|
||||
def run(self, state):
|
||||
seen.append(self.name)
|
||||
return PassReport(pass_name=self.name)
|
||||
|
|
@ -148,6 +150,7 @@ def test_legacy_vnext_corpus_module_reexports_figure_domain_types():
|
|||
|
||||
assert LegacyModuleFigureCorpus is DomainFigureCorpus
|
||||
|
||||
|
||||
def test_resource_ref_supports_text_kind_and_role() -> None:
|
||||
from paperforge.worker.ocr_pairing_types import ResourceRef
|
||||
|
||||
|
|
@ -198,3 +201,46 @@ def test_pipeline_state_alias_preserves_figure_state_compatibility() -> None:
|
|||
assert FigurePipelineState is PipelineState
|
||||
assert state.matches == []
|
||||
assert state.reservations == []
|
||||
|
||||
|
||||
def test_state_accept_match_defaults_to_no_enricher() -> None:
|
||||
"""The generic state must not enrich matches by default (domain-neutral)."""
|
||||
from paperforge.worker.ocr_pairing_state import OwnershipLedger, PipelineState
|
||||
|
||||
state = PipelineState(corpus=None, candidate_index=None, ledger=OwnershipLedger())
|
||||
assert state._match_pre_enricher is None
|
||||
|
||||
|
||||
def test_state_accept_match_invokes_enricher_when_set() -> None:
|
||||
"""When a _match_pre_enricher hook is wired, accept_match must call it."""
|
||||
from paperforge.worker.ocr_pairing_state import FigurePipelineState, OwnershipLedger
|
||||
from paperforge.worker.ocr_pairing_types import ClaimProposal, ResourceRef
|
||||
|
||||
state = FigurePipelineState(corpus=None, candidate_index=None, ledger=OwnershipLedger())
|
||||
called_with: list[tuple] = []
|
||||
|
||||
def fake_enricher(proposal, match_record, state_obj):
|
||||
called_with.append((proposal, match_record, state_obj))
|
||||
|
||||
state._match_pre_enricher = fake_enricher
|
||||
proposal = ClaimProposal(
|
||||
pass_name="test",
|
||||
figure_no=None,
|
||||
claim_type="match",
|
||||
legends=[ResourceRef(kind="legend", page=1, block_id="l1")],
|
||||
assets=[],
|
||||
groups=[],
|
||||
confidence=1.0,
|
||||
evidence_rank=0,
|
||||
reason="test",
|
||||
)
|
||||
match_record = {"test": True}
|
||||
state.accept_match(proposal, match_record)
|
||||
|
||||
assert len(called_with) == 1
|
||||
assert called_with[0][0] is proposal
|
||||
assert called_with[0][1] is match_record
|
||||
assert called_with[0][2] is state
|
||||
# The match_record should have been appended to state.matches *after* enrichment
|
||||
assert state.matches[0] is match_record
|
||||
assert state.matches[0]["test"] is True
|
||||
|
|
|
|||
|
|
@ -5,9 +5,29 @@ def test_table_corpus_collects_captions_assets_and_page_context() -> None:
|
|||
from paperforge.worker.ocr_table_domain import TableCorpus
|
||||
|
||||
blocks = [
|
||||
{"block_id": "cap1", "page": 5, "role": "table_caption", "text": "Table 1. Example", "bbox": [100, 100, 700, 140]},
|
||||
{"block_id": "asset1", "page": 5, "role": "table_html", "raw_label": "table", "text": "<table><tr><td>x</td></tr></table>", "bbox": [100, 160, 700, 500]},
|
||||
{"block_id": "note1", "page": 5, "role": "footnote", "raw_label": "vision_footnote", "text": "* p < 0.05", "bbox": [100, 520, 300, 550]},
|
||||
{
|
||||
"block_id": "cap1",
|
||||
"page": 5,
|
||||
"role": "table_caption",
|
||||
"text": "Table 1. Example",
|
||||
"bbox": [100, 100, 700, 140],
|
||||
},
|
||||
{
|
||||
"block_id": "asset1",
|
||||
"page": 5,
|
||||
"role": "table_html",
|
||||
"raw_label": "table",
|
||||
"text": "<table><tr><td>x</td></tr></table>",
|
||||
"bbox": [100, 160, 700, 500],
|
||||
},
|
||||
{
|
||||
"block_id": "note1",
|
||||
"page": 5,
|
||||
"role": "footnote",
|
||||
"raw_label": "vision_footnote",
|
||||
"text": "* p < 0.05",
|
||||
"bbox": [100, 520, 300, 550],
|
||||
},
|
||||
]
|
||||
|
||||
corpus = TableCorpus.from_blocks(blocks)
|
||||
|
|
@ -22,9 +42,28 @@ def test_table_candidate_index_materializes_caption_records_and_assets_by_page()
|
|||
from paperforge.worker.ocr_table_domain import TableCandidateIndex, TableCorpus
|
||||
|
||||
blocks = [
|
||||
{"block_id": "cap2", "page": 6, "role": "table_caption_candidate", "text": "Table 2. (continued)", "bbox": [100, 100, 700, 130]},
|
||||
{"block_id": "cap1", "page": 5, "role": "table_caption", "text": "Table 1. Example", "bbox": [100, 100, 700, 140]},
|
||||
{"block_id": "asset1", "page": 5, "role": "table_html", "raw_label": "table", "text": "<table></table>", "bbox": [100, 160, 700, 500]},
|
||||
{
|
||||
"block_id": "cap2",
|
||||
"page": 6,
|
||||
"role": "table_caption_candidate",
|
||||
"text": "Table 2. (continued)",
|
||||
"bbox": [100, 100, 700, 130],
|
||||
},
|
||||
{
|
||||
"block_id": "cap1",
|
||||
"page": 5,
|
||||
"role": "table_caption",
|
||||
"text": "Table 1. Example",
|
||||
"bbox": [100, 100, 700, 140],
|
||||
},
|
||||
{
|
||||
"block_id": "asset1",
|
||||
"page": 5,
|
||||
"role": "table_html",
|
||||
"raw_label": "table",
|
||||
"text": "<table></table>",
|
||||
"bbox": [100, 160, 700, 500],
|
||||
},
|
||||
]
|
||||
|
||||
index = TableCandidateIndex.from_corpus(TableCorpus.from_blocks(blocks))
|
||||
|
|
@ -38,7 +77,9 @@ def test_assemble_table_inventory_preserves_public_shape_for_empty_state() -> No
|
|||
from paperforge.worker.ocr_pairing_state import OwnershipLedger, PipelineState
|
||||
from paperforge.worker.ocr_table_domain import TableCandidateIndex, TableCorpus, assemble_table_inventory
|
||||
|
||||
blocks = [{"block_id": "cap1", "page": 1, "role": "table_caption", "text": "Table 1. Example", "bbox": [0, 0, 10, 10]}]
|
||||
blocks = [
|
||||
{"block_id": "cap1", "page": 1, "role": "table_caption", "text": "Table 1. Example", "bbox": [0, 0, 10, 10]}
|
||||
]
|
||||
corpus = TableCorpus.from_blocks(blocks)
|
||||
index = TableCandidateIndex.from_corpus(corpus)
|
||||
state = PipelineState(corpus=corpus, candidate_index=index, ledger=OwnershipLedger())
|
||||
|
|
@ -46,20 +87,40 @@ def test_assemble_table_inventory_preserves_public_shape_for_empty_state() -> No
|
|||
inventory = assemble_table_inventory(state, index)
|
||||
|
||||
assert inventory == {
|
||||
"official_table_count": 0, "held_tables": [],
|
||||
"tables": [{
|
||||
"asset_bbox": [], "asset_block_id": None, "assistive_text": "",
|
||||
"bridge_block_ids": [], "candidate_assets": [],
|
||||
"caption_block_id": "cap1", "caption_text": "Table 1. Example",
|
||||
"consumed_block_ids": [], "continuation_of": None,
|
||||
"formal_table_number": 1, "has_asset": False,
|
||||
"is_continuation": False, "match_score": {"decision": "unmatched", "evidence": [], "matched_asset_id": "", "score": 0.0},
|
||||
"match_status": "pending", "note_band_bbox": [], "note_bboxes": [],
|
||||
"note_block_ids": [], "note_confidence": 0.0, "note_match_reason": "",
|
||||
"note_texts": [], "page": 1, "render_bbox": None, "render_rotation_deg": 0,
|
||||
"segments": [], "table_number": 1, "truth_source": "image",
|
||||
}],
|
||||
"unmatched_assets": [], "unmatched_captions": [blocks[0]],
|
||||
"official_table_count": 0,
|
||||
"held_tables": [],
|
||||
"tables": [
|
||||
{
|
||||
"asset_bbox": [],
|
||||
"asset_block_id": None,
|
||||
"assistive_text": "",
|
||||
"bridge_block_ids": [],
|
||||
"candidate_assets": [],
|
||||
"caption_block_id": "cap1",
|
||||
"caption_text": "Table 1. Example",
|
||||
"consumed_block_ids": [],
|
||||
"continuation_of": None,
|
||||
"formal_table_number": 1,
|
||||
"has_asset": False,
|
||||
"is_continuation": False,
|
||||
"match_score": {"decision": "unmatched", "evidence": [], "matched_asset_id": "", "score": 0.0},
|
||||
"match_status": "pending",
|
||||
"note_band_bbox": [],
|
||||
"note_bboxes": [],
|
||||
"note_block_ids": [],
|
||||
"note_confidence": 0.0,
|
||||
"note_match_reason": "",
|
||||
"note_texts": [],
|
||||
"page": 1,
|
||||
"render_bbox": None,
|
||||
"render_rotation_deg": 0,
|
||||
"segments": [],
|
||||
"table_number": 1,
|
||||
"truth_source": "image",
|
||||
}
|
||||
],
|
||||
"unmatched_assets": [],
|
||||
"unmatched_captions": [blocks[0]],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -70,9 +131,29 @@ def test_build_table_inventory_vnext_matches_same_page_best_asset() -> None:
|
|||
from paperforge.worker.ocr_tables import build_table_inventory_vnext
|
||||
|
||||
structured_blocks = [
|
||||
{"block_id": "cap1", "page": 3, "role": "table_caption", "text": "Table 1. Example", "bbox": [100, 100, 700, 140]},
|
||||
{"block_id": "asset_good", "page": 3, "role": "table_html", "raw_label": "table", "text": "<table></table>", "bbox": [100, 160, 700, 500]},
|
||||
{"block_id": "asset_bad", "page": 3, "role": "media_asset", "raw_label": "image", "text": "", "bbox": [800, 160, 1100, 300]},
|
||||
{
|
||||
"block_id": "cap1",
|
||||
"page": 3,
|
||||
"role": "table_caption",
|
||||
"text": "Table 1. Example",
|
||||
"bbox": [100, 100, 700, 140],
|
||||
},
|
||||
{
|
||||
"block_id": "asset_good",
|
||||
"page": 3,
|
||||
"role": "table_html",
|
||||
"raw_label": "table",
|
||||
"text": "<table></table>",
|
||||
"bbox": [100, 160, 700, 500],
|
||||
},
|
||||
{
|
||||
"block_id": "asset_bad",
|
||||
"page": 3,
|
||||
"role": "media_asset",
|
||||
"raw_label": "image",
|
||||
"text": "",
|
||||
"bbox": [800, 160, 1100, 300],
|
||||
},
|
||||
]
|
||||
|
||||
inventory = build_table_inventory_vnext(structured_blocks)
|
||||
|
|
@ -86,9 +167,13 @@ def test_build_table_inventory_vnext_holds_validation_first_weak_caption_without
|
|||
|
||||
structured_blocks = [
|
||||
{
|
||||
"block_id": "cap1", "page": 4, "role": "body_text",
|
||||
"text": "Table 2", "bbox": [100, 100, 260, 130],
|
||||
"zone": "display_zone", "style_family": "table_caption_like",
|
||||
"block_id": "cap1",
|
||||
"page": 4,
|
||||
"role": "body_text",
|
||||
"text": "Table 2",
|
||||
"bbox": [100, 100, 260, 130],
|
||||
"zone": "display_zone",
|
||||
"style_family": "table_caption_like",
|
||||
"marker_signature": {"type": "table_number"},
|
||||
}
|
||||
]
|
||||
|
|
@ -105,7 +190,14 @@ def test_build_table_inventory_vnext_materializes_split_caption_continuation() -
|
|||
structured_blocks = [
|
||||
{"block_id": "cap1", "page": 5, "role": "table_caption", "text": "Table 3.", "bbox": [100, 100, 220, 130]},
|
||||
{"block_id": "cap2", "page": 5, "role": "body_text", "text": "Continuation text", "bbox": [100, 132, 700, 170]},
|
||||
{"block_id": "asset1", "page": 5, "role": "table_html", "raw_label": "table", "text": "<table></table>", "bbox": [100, 180, 700, 500]},
|
||||
{
|
||||
"block_id": "asset1",
|
||||
"page": 5,
|
||||
"role": "table_html",
|
||||
"raw_label": "table",
|
||||
"text": "<table></table>",
|
||||
"bbox": [100, 180, 700, 500],
|
||||
},
|
||||
]
|
||||
|
||||
inventory = build_table_inventory_vnext(structured_blocks)
|
||||
|
|
@ -118,8 +210,21 @@ def test_build_table_inventory_vnext_previous_page_continuation_gets_geometry_el
|
|||
from paperforge.worker.ocr_tables import build_table_inventory_vnext
|
||||
|
||||
structured_blocks = [
|
||||
{"block_id": "asset_prev", "page": 7, "role": "table_html", "raw_label": "table", "text": "<table></table>", "bbox": [100, 1200, 900, 1480]},
|
||||
{"block_id": "cap1", "page": 8, "role": "table_caption", "text": "Table 4. (continued)", "bbox": [100, 110, 700, 150]},
|
||||
{
|
||||
"block_id": "asset_prev",
|
||||
"page": 7,
|
||||
"role": "table_html",
|
||||
"raw_label": "table",
|
||||
"text": "<table></table>",
|
||||
"bbox": [100, 1200, 900, 1480],
|
||||
},
|
||||
{
|
||||
"block_id": "cap1",
|
||||
"page": 8,
|
||||
"role": "table_caption",
|
||||
"text": "Table 4. (continued)",
|
||||
"bbox": [100, 110, 700, 150],
|
||||
},
|
||||
]
|
||||
|
||||
inventory = build_table_inventory_vnext(structured_blocks)
|
||||
|
|
@ -135,10 +240,30 @@ def test_build_table_inventory_vnext_collects_note_band_and_bridge_blocks() -> N
|
|||
from paperforge.worker.ocr_tables import build_table_inventory_vnext
|
||||
|
||||
structured_blocks = [
|
||||
{"block_id": "cap1", "page": 5, "role": "table_caption", "text": "Table 1. Example", "bbox": [100, 100, 700, 140]},
|
||||
{"block_id": "asset1", "page": 5, "role": "table_html", "raw_label": "table", "text": "<table></table>", "bbox": [100, 160, 700, 520]},
|
||||
{
|
||||
"block_id": "cap1",
|
||||
"page": 5,
|
||||
"role": "table_caption",
|
||||
"text": "Table 1. Example",
|
||||
"bbox": [100, 100, 700, 140],
|
||||
},
|
||||
{
|
||||
"block_id": "asset1",
|
||||
"page": 5,
|
||||
"role": "table_html",
|
||||
"raw_label": "table",
|
||||
"text": "<table></table>",
|
||||
"bbox": [100, 160, 700, 520],
|
||||
},
|
||||
{"block_id": "note1", "page": 5, "role": "footnote", "text": "* p < 0.05", "bbox": [100, 530, 220, 555]},
|
||||
{"block_id": "bridge1", "page": 5, "bridge_eligible": True, "layout_region": "display_zone", "text": "", "bbox": [100, 150, 700, 155]},
|
||||
{
|
||||
"block_id": "bridge1",
|
||||
"page": 5,
|
||||
"bridge_eligible": True,
|
||||
"layout_region": "display_zone",
|
||||
"text": "",
|
||||
"bbox": [100, 150, 700, 155],
|
||||
},
|
||||
]
|
||||
|
||||
tables = build_table_inventory_vnext(structured_blocks)["tables"]
|
||||
|
|
@ -153,22 +278,52 @@ def test_build_table_inventory_vnext_respects_page_footnote_prior() -> None:
|
|||
from paperforge.worker.ocr_tables import build_table_inventory_vnext
|
||||
|
||||
structured_blocks = [
|
||||
{"block_id": "cap1", "page": 5, "role": "table_caption", "text": "Table 1. Example", "bbox": [100, 100, 700, 140]},
|
||||
{"block_id": "asset1", "page": 5, "role": "table_html", "raw_label": "table", "text": "<table></table>", "bbox": [100, 160, 700, 1150]},
|
||||
{"block_id": "note1", "page": 5, "role": "footnote", "text": "* footer-area note", "bbox": [100, 1180, 300, 1210]},
|
||||
{
|
||||
"block_id": "cap1",
|
||||
"page": 5,
|
||||
"role": "table_caption",
|
||||
"text": "Table 1. Example",
|
||||
"bbox": [100, 100, 700, 140],
|
||||
},
|
||||
{
|
||||
"block_id": "asset1",
|
||||
"page": 5,
|
||||
"role": "table_html",
|
||||
"raw_label": "table",
|
||||
"text": "<table></table>",
|
||||
"bbox": [100, 160, 700, 1150],
|
||||
},
|
||||
{
|
||||
"block_id": "note1",
|
||||
"page": 5,
|
||||
"role": "footnote",
|
||||
"text": "* footer-area note",
|
||||
"bbox": [100, 1180, 300, 1210],
|
||||
},
|
||||
]
|
||||
|
||||
table = build_table_inventory_vnext(structured_blocks)["tables"][0]
|
||||
assert table["note_match_reason"] in {"note_band_geometry_match", "outside_vertical_range"}
|
||||
|
||||
|
||||
def test_build_table_inventory_vnext_sets_render_rotation_fields_for_rotated_table_asset() -> None:
|
||||
from paperforge.worker.ocr_tables import build_table_inventory_vnext
|
||||
|
||||
structured_blocks = [
|
||||
{"block_id": "cap1", "page": 8, "role": "table_caption", "text": "Table 2. Caption", "bbox": [100, 134, 967, 1442]},
|
||||
{
|
||||
"block_id": "asset1", "page": 8, "role": "table_html", "raw_label": "table",
|
||||
"text": "<table></table>", "bbox": [100, 100, 880, 1400],
|
||||
"block_id": "cap1",
|
||||
"page": 8,
|
||||
"role": "table_caption",
|
||||
"text": "Table 2. Caption",
|
||||
"bbox": [100, 134, 967, 1442],
|
||||
},
|
||||
{
|
||||
"block_id": "asset1",
|
||||
"page": 8,
|
||||
"role": "table_html",
|
||||
"raw_label": "table",
|
||||
"text": "<table></table>",
|
||||
"bbox": [100, 100, 880, 1400],
|
||||
"span_metadata": [{"dir": [0.0, -1.0], "wmode": 0}],
|
||||
},
|
||||
]
|
||||
|
|
@ -181,6 +336,7 @@ def test_build_table_inventory_vnext_sets_render_rotation_fields_for_rotated_tab
|
|||
|
||||
def test_compare_table_inventory_legacy_vs_vnext_smoke_fixture() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.dev.compare_table_inventory_legacy_vs_vnext import compare_table_inventory_legacy_vs_vnext
|
||||
|
||||
result = compare_table_inventory_legacy_vs_vnext(Path("tests/fixtures/ocr_vnext_real_papers/2HEUD5P9"))
|
||||
|
|
@ -188,3 +344,107 @@ def test_compare_table_inventory_legacy_vs_vnext_smoke_fixture() -> None:
|
|||
assert "legacy" in result
|
||||
assert "vnext" in result
|
||||
assert "diff" in result
|
||||
|
||||
|
||||
def test_compare_table_inventory_parity_on_all_real_paper_fixtures() -> None:
|
||||
"""Semantic parity after normalising benign drift (string/int, ordering, None vs '').
|
||||
|
||||
Runnable fixtures: 2HEUD5P9, 28JLIHLS, YGH7VEX6, DWQQK2YB, 24YKLTHQ, 37LK5T97.
|
||||
The first five achieve full parity across all fields including consumed_block_ids.
|
||||
|
||||
37LK5T97 gap: consumed_block_ids in legacy drops caption block_id=0 because
|
||||
the ``if bid`` filter treats integer 0 as falsy; vnext uses string ``'0'``
|
||||
which is truthy. This is a known legacy bug that vnext fixes — the core
|
||||
fields (caption_block_id, page, formal_table_number, asset_block_id,
|
||||
match_status, render_rotation_deg) are identical after normalisation.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.dev.compare_table_inventory_legacy_vs_vnext import compare_table_inventory_legacy_vs_vnext
|
||||
|
||||
fixtures = [
|
||||
("2HEUD5P9", False),
|
||||
("28JLIHLS", False),
|
||||
("YGH7VEX6", False),
|
||||
("DWQQK2YB", False),
|
||||
("24YKLTHQ", False),
|
||||
("37LK5T97", True), # True = known consumed_block_ids gap
|
||||
]
|
||||
|
||||
core_fields = {
|
||||
"caption_block_id",
|
||||
"page",
|
||||
"formal_table_number",
|
||||
"asset_block_id",
|
||||
"match_status",
|
||||
"render_rotation_deg",
|
||||
}
|
||||
|
||||
for pid, known_consumed_gap in fixtures:
|
||||
result = compare_table_inventory_legacy_vs_vnext(Path(f"tests/fixtures/ocr_vnext_real_papers/{pid}"))
|
||||
|
||||
assert len(result["legacy"]) == len(result["vnext"]), (
|
||||
f"{pid}: table count mismatch: {len(result['legacy'])} vs {len(result['vnext'])}"
|
||||
)
|
||||
|
||||
lo = result["diff"]["legacy_only"]
|
||||
vo = result["diff"]["vnext_only"]
|
||||
|
||||
# Compare core fields directly: for each fixture paper, every
|
||||
# legacy table's core fields must appear in the vnext list and
|
||||
# vice versa. This catches real semantic drift.
|
||||
legacy_core = {tuple(t.get(k) for k in core_fields) for t in result["legacy"]}
|
||||
vnext_core = {tuple(t.get(k) for k in core_fields) for t in result["vnext"]}
|
||||
|
||||
legacy_missing = legacy_core - vnext_core
|
||||
vnext_extra = vnext_core - legacy_core
|
||||
|
||||
assert not legacy_missing and not vnext_extra, (
|
||||
f"{pid}: core-field mismatch — "
|
||||
f"legacy has {legacy_missing} not in vnext, "
|
||||
f"vnext has {vnext_extra} not in legacy"
|
||||
)
|
||||
|
||||
# consumed_block_ids / note / bridge diffs: must be the known gap or absent.
|
||||
consumed_lo = {tuple(t["consumed_block_ids"]) for t in lo}
|
||||
consumed_vo = {tuple(t["consumed_block_ids"]) for t in vo}
|
||||
|
||||
if known_consumed_gap:
|
||||
assert consumed_lo or consumed_vo, f"{pid}: expected consumed_block_ids gap but found none"
|
||||
# note / bridge must be pair-wise equivalent across lo and vo
|
||||
# (same empty values for 37LK5T97 — no notes or bridges).
|
||||
lo_pairs = {(tuple(t.get("note_block_ids")), tuple(t.get("bridge_block_ids"))) for t in lo}
|
||||
vo_pairs = {(tuple(t.get("note_block_ids")), tuple(t.get("bridge_block_ids"))) for t in vo}
|
||||
assert lo_pairs == vo_pairs, (
|
||||
f"{pid}: note/bridge mismatch beyond consumed_block_ids gap: lo={lo_pairs} vo={vo_pairs}"
|
||||
)
|
||||
else:
|
||||
assert not consumed_lo and not consumed_vo, (
|
||||
f"{pid}: unexpected consumed_block_ids diffs: legacy={consumed_lo}, vnext={consumed_vo}"
|
||||
)
|
||||
|
||||
|
||||
def test_table_pipeline_does_not_set_figure_enricher() -> None:
|
||||
"""Table pipeline state must not wire the figure-only rotation enricher."""
|
||||
from paperforge.worker.ocr_table_domain import TableCandidateIndex, TableCorpus
|
||||
|
||||
# Build a minimal table inventory — the state created internally must not
|
||||
# have the figure rotation enricher set.
|
||||
structured_blocks = [
|
||||
{
|
||||
"block_id": "cap1",
|
||||
"page": 1,
|
||||
"role": "table_caption",
|
||||
"text": "Table 1. Results.",
|
||||
"bbox": [100, 50, 500, 80],
|
||||
},
|
||||
{"block_id": "tab1", "page": 1, "role": "table_asset", "bbox": [100, 100, 500, 400]},
|
||||
]
|
||||
from paperforge.worker.ocr_pairing_state import OwnershipLedger, PipelineState
|
||||
|
||||
corpus = TableCorpus.from_blocks(structured_blocks)
|
||||
candidate_index = TableCandidateIndex.from_corpus(corpus)
|
||||
state = PipelineState(corpus=corpus, candidate_index=candidate_index, ledger=OwnershipLedger())
|
||||
|
||||
# Table pipeline should never set the figure-only enricher
|
||||
assert state._match_pre_enricher is None
|
||||
|
|
|
|||
Loading…
Reference in a new issue