docs: four-PR architecture hardening spec

Based on layout truth audit findings (6 bug patterns), designs 4
independent PRs with precise code locations, helper contracts,
column-aware zone inference, caption formalness tightening,
and render frontmatter fallback. Includes before/after Mermaid
diagrams, test specs, and execution order.
This commit is contained in:
LLLin000 2026-07-04 21:34:02 +08:00
parent 26715a9d7e
commit bdf96af6fd

View file

@ -0,0 +1,564 @@
# Four-PR Architecture Hardening Design
> **Date:** 2026-07-05
> **Status:** Draft — ready for implementation
> **Audit Source:** `docs/superpowers/analysis/2026-07-05-layout-truth-audit-findings.md`
> **Design review:** GPT-analyzed, 6 bug patterns, 4 PR execution plan
---
## Design Principle
Do not layer new heuristics on top of old ones. Each bug pattern signals a **missing signal** that already exists elsewhere in the pipeline but is not connected to the decision point. The fix is to complete the signal path, not to add another gate.
```
Role layer: seed role misclassifications only — Fig. N shows ≠ formal caption
Document layer: zone boundary must be column-aware, not page-level
Pairing layer: caption-asset matching must check column compatibility
Render layer: metadata consumption must have fallback when metadata is empty
```
---
## Architecture Diagram (Before)
```mermaid
flowchart TB
subgraph assign_block_role["assign_block_role()"]
heuristic["_is_obviously_formal_figure_caption()<br/>verb-based negative check<br/>'Fig. N shows...' leaks through"]
heading["page 1 heading detection<br/>'ARTICLE INFO' → unknown_structural<br/>'RESULTS AND DISCUSSION' → paper_title"]
end
subgraph infer_zones["infer_zones()"]
ref_boundary["_is_below_same_page_reference_heading()<br/>page-level y comparison<br/>ignores column boundaries"]
end
subgraph pairing["figure_pairing (vnext)"]
scoring["_score_legend_to_group()<br/>spatial + text scores only<br/>no column compatibility check"]
end
subgraph render["render_fulltext_markdown()"]
skip["CONSUMED_FRONTMATTER_ROLES skip<br/>page <= 2 → continue<br/>NO fallback when metadata empty"]
body["body text rendering<br/>authors/affiliations silently dropped"]
end
heuristic -->|false negative| caption["figure_caption_candidate"]
caption -->|phantom figure| invalid_matches["cascading ownership failures"]
ref_boundary -->|two-column| zone_pollution["body → backmatter zone"]
zone_pollution -->|zone-based role| tail_settlement["tail settlement misclassifies body"]
scoring -->|no column gate| cross_col["left-column asset → right-column caption"]
skip -->|authors missing| user["user sees incomplete fulltext"]
style heuristic fill:#fef2f2,stroke:#dc2626
style ref_boundary fill:#fef2f2,stroke:#dc2626
style scoring fill:#fef2f2,stroke:#dc2626
style skip fill:#fef2f2,stroke:#dc2626
```
---
## Architecture Diagram (After)
```mermaid
flowchart TB
subgraph assign_block_role["assign_block_role()"]
new_heuristic["_is_obviously_formal_figure_caption()<br/>positive evidence + inline mention reject<br/>'Fig. N shows...' → body_paragraph"]
fh["_is_short_structural_heading()<br/>'ARTICLE INFO' → frontmatter_heading"]
end
subgraph infer_zones["infer_zones()"]
col_aware["_is_below_same_page_reference_heading()<br/>+ column band check<br/>left-column body NOT pulled"]
end
subgraph pairing["figure_pairing (vnext)"]
col_scoring["_score_legend_to_group()<br/>+ _column_compatible_for_caption_asset()<br/>cross-column → score=0, rejected"]
end
subgraph render["render_fulltext_markdown()"]
fallback["_collect_frontmatter_fallback_lines()<br/>runs before body loop<br/>metadata empty → block fallback"]
safe_skip["CONSUMED_FRONTMATTER_ROLES skip<br/>page <= 2 → continue<br/>SAFE: already consumed"]
end
new_heuristic -->|correct| body_role["body_paragraph"]
new_heuristic -->|formal caption preserved| formal["figure_caption"]
col_aware -->|left column| body_zone["body_zone ✓"]
col_aware -->|right column| ref_zone["reference_zone ✓"]
col_scoring -->|rejected| no_match["no cross-column match"]
col_scoring -->|compatible| normal_match["normal match"]
fallback -->|metadata present| skip_block["skip block (existing contract)"]
fallback -->|metadata empty| render_content["render block content"]
style new_heuristic fill:#eef2ff,stroke:#6366f1
style col_aware fill:#eef2ff,stroke:#6366f1
style col_scoring fill:#eef2ff,stroke:#6366f1
style fallback fill:#eef2ff,stroke:#6366f1
```
---
## PR 1: Frontmatter Render Fallback
**Risk:** Low. **User impact:** High (authors/affiliations missing from output).
### Problem
`render_fulltext_markdown()` at `ocr_render.py:11601866` defines:
```python
CONSUMED_FRONTMATTER_ROLES = frozenset({
"paper_title", "authors", "doi", "affiliation", "email", "correspondence",
})
```
Then in the body loop:
```python
if role in CONSUMED_FRONTMATTER_ROLES and int(block.get("page", 0) or 0) <= 2:
continue
```
And the metadata section only renders:
```python
authors_display = resolved_metadata.get("authors_display", "")
if not authors_display:
authors = resolved_metadata.get("authors", {}).get("value", [])
if authors:
authors_display = ", ".join(authors)
if authors_display or journal or year or doi:
lines.append("> [!info]- Paper Metadata")
```
**Result:** When `resolved_metadata["authors"]` is empty, the skip contract is violated — blocks are skipped but nothing replaces them. The title is always rendered via `resolved_metadata["title"]`, but authors/affiliations have no fallback.
### Solution
#### New function: `_collect_frontmatter_fallback_lines()`
```python
def _collect_frontmatter_fallback_lines(
structured_blocks: list[dict],
resolved_metadata: dict,
) -> list[str]:
```
**Contract:**
- `paper_title`: only when `resolved_metadata["title"]["value"]` is empty
- `authors`: only when both `authors_display` and `resolved_metadata["authors"]["value"]` are empty
- `affiliation` / `email` / `correspondence`: when metadata has no equivalent field
- `doi`: never — metadata has DOI, avoid duplicate
**Implementation:**
1. Scan `structured_blocks` for blocks with role in `CONSUMED_FRONTMATTER_ROLES` on pages 12
2. Group by role
3. Only collect blocks for which metadata is missing
4. Return formatted lines (under an existing metadata callout if present, or create one)
**Insertion point:** After existing metadata section, before body loop. Keep the `continue` skip unchanged — this makes the skip safe rather than removing it.
**Do NOT:**
- Remove the `continue` skip — that would render frontmatter blocks as body text
- Add role exceptions inside the body loop
- Change `render_default` on any block
#### Tests
```python
def test_render_frontmatter_authors_fallback_when_metadata_empty():
"""Page 1 authors block appears in metadata callout when resolved_metadata has no authors."""
def test_render_frontmatter_affiliations_fallback_when_metadata_empty():
"""Page 1-2 affiliation blocks appear in metadata callout when none in metadata."""
def test_render_frontmatter_does_not_duplicate_when_metadata_present():
"""Metadata callout has authors from resolved_metadata; frontmatter blocks still skipped."""
def test_render_frontmatter_title_fallback_when_title_metadata_empty():
"""Paper_title block rendered when resolved_metadata title missing."""
def test_render_frontmatter_doi_not_fallback():
"""DOI block never rendered as fallback — metadata DOI is authoritative."""
```
**Files touched:** `paperforge/worker/ocr_render.py`, `tests/test_ocr_render.py`
---
## PR 2: Formal Figure Caption Heuristic Tightening
**Risk:** Medium-low. **Key risk:** false positive on real captions (rotated, sidecar, short). Must preserve existing behavior for all 8 gold fixtures.
### Problem
`_is_obviously_formal_figure_caption()` at `ocr_roles.py:193204`:
```python
verb_patterns = ["shows", "illustrates", "depicts", "demonstrates", "presents", "summarizes"]
has_verb = any(v in text.lower() for v in verb_patterns)
sentence_markers = [" is ", " are ", " was ", " were "]
has_sentence = any(m in text.lower() for m in sentence_markers)
if has_verb and has_sentence:
return False # Only rejects when verb AND sentence marker BOTH present
is_short = len(text) <= 80
near_media = _is_near_figure_media(block, page_blocks)
return is_short or near_media
```
**Bug:** Body text "Fig. 9 shows the Mössbauer spectrum..." has `has_verb=True, has_sentence=False` — passes through as formal caption.
### Solution
#### Phase 1: Add `_INLINE_FIGURE_MENTION_PATTERN` negative check
```python
_INLINE_FIGURE_MENTION_PATTERN = re.compile(
r"^\s*(?:fig(?:ure)?\.?\s+\d+[a-z]?|figs?\.?\s+\d+[a-z]?)\s+"
r"(?:shows?|illustrates?|depicts?|demonstrates?|presents?|summarizes?|"
r"reveals?|indicates?|compares?|contains?|provides?|displays?|represents?)\b",
re.I,
)
```
Insert at top of `_is_obviously_formal_figure_caption()`:
```python
if _looks_like_inline_figure_mention(text, block):
return False
```
#### Phase 2: Convert heuristic from "negative" to "positive evidence"
```python
def _is_obviously_formal_figure_caption(text, block, page_blocks):
if not _has_figure_prefix(text):
return False
if _looks_like_inline_figure_mention(text, block):
return False
near_media = _is_near_figure_media(block, page_blocks)
raw_label = str(block.get("raw_label") or block.get("block_label") or "")
if raw_label == "figure_title":
return True
if near_media and _looks_like_caption_syntax(text):
return True
if len(text) <= 80 and _looks_like_caption_syntax(text):
return True
return False
```
#### New helper: `_looks_like_caption_syntax()`
Narrow: requires `Figure N` + delimiter (`.`, `:`, `|`, `—`) or caption-like short title.
```python
_CAPTION_SYNTAX_PATTERN = re.compile(
r"(?:figure|fig\.?)\s+\d+[a-z]?\s*[.:|—–]",
re.I,
)
def _looks_like_caption_syntax(text: str) -> bool:
"""Formal caption requires numbered prefix with delimiter."""
return bool(_CAPTION_SYNTAX_PATTERN.search(text))
```
#### Architecture note: shared pattern source
`ocr_figures.py` has `_looks_like_figure_narrative_prose()`. Late resolution has `_looks_like_late_figure_narrative_prose()`. The new `_looks_like_inline_figure_mention()` should be defined in `ocr_roles.py` and used by both early and late resolution.
#### Tests
```python
def test_fig_shows_body_mention_not_formal_caption():
""""Fig. 9 shows results" → body_paragraph, not figure_caption."""
def test_fig_period_caption_remains_caption():
""""Figure 1. Histological analysis" → figure_caption."""
def test_near_media_does_not_rescue_inline_body_mention():
"""Block near figure_asset but text is "Fig. 2 shows..." → NOT caption."""
def test_figure_title_raw_label_still_caption():
"""raw_label=figure_title always → figure_caption, regardless of text."""
def test_short_near_media_formal_caption_preserved():
""""Fig. 3. Results" near media → figure_caption."""
def test_all_gold_fixtures_preserved():
"""Run 8 fixture-based regression tests — no change in matched figure count."""
```
**Files touched:** `paperforge/worker/ocr_roles.py`, `tests/test_ocr_roles.py`, `tests/test_ocr_figures.py` (one regression gate)
---
## PR 3: Same-Page Reference Boundary — Column-Aware Zone Inference
**Risk:** Medium. **Key constraint:** Must preserve single-column same-page boundary behavior (verified as correct in 28JLIHLS audit).
### Problem
`infer_zones()``_is_below_same_page_reference_heading()` at `ocr_document.py`:
```python
def _is_below_same_page_reference_heading(block, page_blocks):
"""Check if block is below a reference heading on the same page."""
block_y = _block_y_top(block)
for other in page_blocks:
if other is block:
continue
if _is_reference_heading_candidate(other):
other_y = _block_y_top(other)
if other_y and block_y and block_y > other_y:
return True
return False
```
Only checks y-position. On two-column pages where right column has References heading, left-column body below that y is pulled into `same_page_tail_blocks``tail_nonref_hold_zone`.
### Solution
#### New helper: `_is_in_same_reference_column()`
```python
def _is_in_same_reference_column(
block: dict,
ref_heading_block: dict | None,
page_width: float,
) -> bool:
```
**Logic:**
1. If `ref_heading_block is None`: return False (no reference heading to compare to)
2. If block bbox or ref heading bbox is missing: return True (conservative — don't change existing behavior for missing data)
3. If ref heading is full-width (covers >70% of page width): return True (page-level boundary, same as current behavior)
4. Otherwise: check column compatibility:
- Calculate column band for each (left=0, right=1, center=ambiguous)
- Same band → True
- Different bands → False
- Either ambiguous → True (conservative)
- Block is full-width → True
#### Column helper placement
Add to `ocr_document.py` as private function. Do NOT import `_column_band_id` from `ocr_figures.py` (avoids cross-module dependency from document layer to figure layer). The column logic is simple enough to duplicate:
```python
def _block_column_band(bbox, page_width):
"""0=left, 1=right, None=center/ambiguous."""
if not bbox or len(bbox) < 4 or not page_width:
return None
cx = (bbox[0] + bbox[2]) / 2.0
if cx < page_width * 0.45:
return 0
if cx > page_width * 0.55:
return 1
return None
```
#### Modification point
The `same_page_tail_blocks` list comprehension in `infer_zones()`:
```diff
same_page_tail_blocks = [
block
for block in blocks
- if _is_below_same_page_reference_heading(block, page_blocks)
+ if _is_below_same_page_reference_heading(block, page_blocks)
+ and _is_in_same_reference_column(
+ block,
+ _find_same_page_reference_heading(block, page_blocks),
+ page_width,
+ )
...
]
```
Need to also add `_find_same_page_reference_heading()` — returns the specific ref heading block that `_is_below_same_page_reference_heading` detected, for column comparison.
#### Tests
```python
def test_same_page_reference_heading_does_not_pull_left_column_conclusion():
"""Two-column: left column Conclusions NOT pulled into tail zone when ref heading is in right column."""
def test_same_page_reference_heading_pulls_same_column_tail_note():
"""Two-column: right-column note below ref heading IS pulled into tail zone."""
def test_single_column_same_page_reference_boundary_still_works():
"""Single-column same-page reference boundary still correctly detected."""
def test_same_page_reference_heading_full_width_ref_still_page_level():
"""Full-width reference heading still pulls page-level body below it."""
def test_same_page_reference_heading_no_ref_on_page_no_change():
"""Page without reference heading — no behavior change."""
def test_28jlihls_single_column_clear_separation_preserved():
"""Fixture: single-column same-page boundary remains FALSE POSITIVE (no false alarm)."""
```
**Files touched:** `paperforge/worker/ocr_document.py`, `tests/test_ocr_document.py`
---
## PR 4: VNext Figure Pairing — Column Compatibility in Candidate Scoring
**Risk:** Medium. **Key constraint:** Sidecar captions (same-row adjacent column) must still match.
### Problem
`PrimarySamePagePass` at `ocr_figure_vnext_passes.py` calls `_score_legend_to_group()` in `ocr_figures.py`. The scoring function considers spatial distance, text overlap, and orientation, but does NOT check column compatibility. A left-column figure asset can be matched to a right-column figure caption if they are vertically close.
`_is_safe_page_assets_group()` (`ocr_figures.py:283`) does have a column band gate that rejects cross-column groups, but this gate only applies to the `page_assets` grouping step, not to the caption-to-group scoring step. Column information is computed during grouping but never passed to scoring.
### Solution
#### 1. New helper: `_column_compatible_for_caption_asset()`
In `ocr_figures.py`:
```python
def _column_compatible_for_caption_asset(
caption_bbox: list[float],
asset_bbox: list[float],
page_width: float,
*,
allow_full_width: bool = True,
) -> tuple[bool, str]:
"""Check if caption and asset are in compatible columns for matching.
Returns (compatible: bool, reason: str).
Compatible means they CAN be the same figure, not that they MUST be.
"""
if not caption_bbox or len(caption_bbox) < 4 or not asset_bbox or len(asset_bbox) < 4:
return True, "no_bbox"
cap_band = _column_band_id(caption_bbox, page_width)
asset_band = _column_band_id(asset_bbox, page_width)
# Either is full-width → always compatible (spans all columns)
if cap_band is None or asset_band is None:
return True, "full_width_or_center"
# Same column → compatible
if cap_band == asset_band:
return True, "same_column"
# Different columns → incompatible
return False, f"cross_column:caption={cap_band}_asset={asset_band}"
```
#### 2. Group-level check in `_score_legend_to_group()`
At the beginning of `_score_legend_to_group()` in `ocr_figures.py`, before per-asset scoring:
```python
def _score_legend_to_group(legend, group, page_width, ...):
# Column compatibility check (group-level)
legend_bbox = legend.get("bbox") or [0, 0, 0, 0]
if not group.get("column_band"):
group["column_band"] = _group_column_band(group.get("media_blocks", []), page_width)
compatible, reason = _column_compatible_for_caption_asset(
legend_bbox, group.get("cluster_bbox", [0, 0, 0, 0]), page_width,
)
if not compatible:
return {
"score": 0.0,
"decision": "rejected",
"evidence": ["cross_column_caption_asset", reason],
}
# ...existing scoring logic...
```
#### 3. Candidate group metadata enrichment
In `_candidate_group_entry()` or equivalent, add:
```python
{
"column_band": _group_column_band(media_blocks, page_width),
"column_evidence": "...",
}
```
`_group_column_band()` returns the dominant column band for the group's media blocks, or `None` if the group spans multiple columns (composite figure).
#### 4. Sidecar exception
Sidecar captions (same row, adjacent column) should still match. The column check should allow matching when:
- Caption and asset are in adjacent columns (band 0→1 or 1→0)
- They have y-overlap (same visual row)
- The caption is narrow (sidecar width < 40% of page width)
```python
def _is_sidecar_exception(caption_bbox, asset_bbox, page_width):
"""Sidecar: same row, adjacent column, narrow caption."""
caption_width = (caption_bbox[2] - caption_bbox[0]) if len(caption_bbox) >= 4 else 0
if caption_width > 0 and caption_width < page_width * 0.4:
cap_band = _column_band_id(caption_bbox, page_width)
asset_band = _column_band_id(asset_bbox, page_width)
if cap_band is not None and asset_band is not None and cap_band != asset_band:
y_overlap = caption_bbox[1] < asset_bbox[3] and caption_bbox[3] > asset_bbox[1]
return y_overlap
return False
```
#### Tests
```python
def test_primary_same_page_rejects_cross_column_caption_asset():
"""Left-column caption NOT matched to right-column asset group."""
def test_primary_same_page_allows_full_width_caption_to_center_asset():
"""Full-width caption in center band matches center asset group."""
def test_sidecar_same_row_adjacent_column_still_matches():
"""Narrow sidecar caption in left column matches same-row right-column asset (sidecar exception)."""
def test_composite_multi_column_group_allows_caption_match():
"""Group spanning both columns (composite figure) allows caption in either column."""
def test_cross_column_rejection_does_not_affect_same_column_matches():
"""All existing same-column fixtures preserve their match count."""
def test_28jlihls_fig5_assets_not_assigned_to_fig6():
"""Regression: Figure 5 left-column assets not assigned to Figure 6 right-column caption."""
```
**Files touched:** `paperforge/worker/ocr_figures.py`, optionally `paperforge/worker/ocr_figure_vnext_passes.py`, `tests/test_ocr_figures.py`
---
## Deferred (Not in the 4-PR Plan)
### PR 5: Frontmatter Heading Exact Labels
Add `_FRONTMATTER_SECTION_LABELS` to catch "ARTICLE INFO" → `frontmatter_heading` or `section_heading`. Insert early in `assign_block_role()` before the paper_title fallback. Low priority — cosmetic role labels.
### PR 6: Supplementary-Only Document Mode
Design a `document_mode` detection at pipeline entry. Render via simple fallback. Not a bug fix — a new feature. Requires separate design.
---
## Execution Order & Dependencies
```
PR 1 (render frontmatter) ──→ independent, do first
PR 2 (caption heuristic) ──→ independent, can parallel with PR 1
PR 3 (zone boundary) ──→ independent of PR 1/2, depends on 28JLIHLS fixture
PR 4 (pairing column) ──→ independent of PR 1/2/3, depends on 28JLIHLS fixture
```
All 4 PRs are logically independent and can be implemented in parallel. Recommended order:
1. **PR 1** — smallest diff, highest user impact, proves the process
2. **PR 3** — zone boundary affects the most papers (395/734 multi-column)
3. **PR 2** — caption heuristic affects figure/table quality
4. **PR 4** — pairing column fixes residual cross-column mis-assignment
### Verification after all 4 PRs
```bash
# Full test suite
python -m pytest tests/test_ocr_*.py -v --tb=short
# Real-paper regressions (8 gold fixtures)
python -m pytest tests/test_ocr_real_paper_regressions.py -v --tb=short
# Vault corpus diff (555 papers)
python scripts/dev/corpus_v3_diff_full.py
```
### Cross-cutting Architecture Rule
Every change in these 4 PRs must pass the **deletion test**: if you delete the function/module you're adding, does complexity vanish or concentrate? If it concentrates (callers would need to reimplement the logic), the change is earning its keep. If it vanishes (the function is a pass-through), it's shallow.
```
Frontmatter_fallback_lines: DELETE → complexity vanishes? Yes → good
_is_in_same_reference_column: DELETE → zone pollution returns → good
_column_compatible_for_caption: DELETE → cross-column matches return → good
_is_obviously_formal_figure_caption tightening: DELETE → Fig-N-shows returns → good
```