feat: figure merge clustering spec + fixes for caption dedup and structural gate

- Add global-distance-clustering figure merge design spec
- Fix ocr_roles.py: body_zone body_paragraph no longer promoted to
  figure_caption_candidate (prevents body mentions from entering legend pipeline)
- Fix ocr_document.py: preserve subsection_heading, reference_item,
  table_caption from structural-gate demotion
- Update PROJECT-MANAGEMENT.md with edge-case analysis
- Add ocr-vision-audit-master.md for CODE vs VISION separation
- Update SKILL.md dispatch rules with trust boundaries
This commit is contained in:
Research Assistant 2026-06-21 21:32:30 +08:00
parent aef0cc0e78
commit da2e018b19
10 changed files with 1858 additions and 115 deletions

View file

@ -15,10 +15,86 @@ Use this when the task is to inspect OCR truth, classify failures, and write aud
## Scripts
- Audit helper: `scripts/ocr_truth_audit.py`
- `scripts/ocr_truth_audit.py` — artifact generator (annotated pages, summaries, findings)
- `scripts/audit_helpers.py` — structured data maps for vision agents (figure, frontmatter, reference, body)
- `scripts/verify_review_coverage.py` — coverage verification
- `scripts/diff_audit.py` — diff audit between truth and pipeline
## Invariant
- Truth first.
- Do not start by authoring expectations.
- Establish block-level truth from page visuals and artifacts before comparing pipeline behavior.
## Agent Dispatch Rules (CRITICAL — read before any audit task)
The #1 source of audit errors is using the wrong tool for the question.
Follow these rules unconditionally:
### When to dispatch `vision` subagent (MUST):
The workflow in `workflows/ocr-truth-audit.md` Step 6 tells you the exact commands.
Summary:
| Audit domain | Data recipe | Vision template |
|-------------|-------------|----------------|
| Figures | `audit_helpers.py --recipe figure_map` | atoms/ocr-vision-audit-templates.md — Template 1 |
| Frontmatter | `audit_helpers.py --recipe frontmatter_map` | Template 2 |
| References | `audit_helpers.py --recipe reference_map` | Template 3 |
| Body text | `audit_helpers.py --recipe body_text_map` | Template 4 |
**Procedure:** (1) Run the recipe → get JSON. (2) Read the template. (3) Paste JSON into template. (4) Dispatch as `vision` subagent prompt.
### Forbidden agent patterns:
- `general` subagent claiming to "see" an annotated page. **Only `vision` can view images.**
- Concluding figure matching from `figure_table_ownership_summary.json` alone. **Must cross-check with source `figure_inventory.json` AND visual truth.**
- Reporting `reference_intrusion_candidates` as errors without checking block roles.**noise blocks in `reference_zone` are expected.**
- "The block has 0 candidates so figure is on another page" without scanning `page_{N-1}_index.json` and `page_{N+1}_index.json`.
### Finding trust boundaries (CRITICAL):
The auto-generated findings in `audit_report.json` contain substantial noise.
**Only these [CODE] checks are reliable — all others need [VISION]:**
| Finding category | Trust? | Why |
|-----------------|--------|-----|
| `role_mismatch` (from diff_audit.py) | ✅ [CODE] | Direct string comparison, 100% |
| `zone_mismatch` (from block_review.jsonl) | ✅ [CODE] | Direct string comparison, 100% |
| `render_mapping_error` — non-noise blocks | ✅ [CODE] | Substring match, exclude noise |
| `unmatched_asset_count > 0` | ✅ [CODE] | deterministic count from inventory |
| `coverage_check status` | ✅ [CODE] | direct comparison |
| `reference_span_audit status` | ✅ [CODE] | deterministic |
| `same_page_boundary_error` | ❌ NEVER | Page-level heuristic, no actionable info |
| `reference_span_error` (audit_report.json) | ⚠️ Partially | Span may be too wide; need vision to verify boundary |
| `object_ownership_error` (audit_report.json) | ❌ NEVER | Misses unmatched assets entirely |
| `render_mapping_error` — noise blocks | ❌ NEVER | noise should not be in fulltext |
| `image_quality`, `font`, `color`, `typography` | ❌ [VISION] only | No metadata available |
### Per-role audit strategies:
Full methodology for each of the 28 canonical roles is in `atoms/ocr-role-audit-strategies.md`.
Each role is classified into one of 4 tiers:
| Tier | Method | Roles |
|------|--------|-------|
| A | Vision-required | `figure_asset`, `media_asset`, `figure_caption`, `figure_caption_candidate`, `table_caption`, `table_asset`, `table_html`, `structured_insert` |
| B | Data-primary, vision-confirm | `body_paragraph`, `section_heading`, `subsection_heading`, `reference_item`, `reference_heading`, `backmatter_heading`, `backmatter_body` |
| C | Data-sufficient | `paper_title`, `authors`, `affiliation`, `abstract_heading`, `abstract_body`, `keywords`, `frontmatter_support`, `frontmatter_noise`, `footnote` |
| D | Structural-only | `tail_candidate_body`, `backmatter_boundary_*`, `ocr_raw_error`, `unknown_structural`, `page_header`, `page_footer`, `noise` |
### Deep per-role + quality audit:
**`atoms/ocr-vision-audit-master.md`** extends all 4 templates with:
- Per-role analysis covering role/zone/reference AND quality (typography, image, table)
- Cross-reference pathways linking all audit data sources for any block_id
- Chart-type routing to `paperforge/skills/paperforge/atoms/chart-reading/*.md`
- Full-page typography sweep (font consistency, alignment, orphans/widows, heading hierarchy)
- Output format for block_review.jsonl with optional `quality_checks` and `page_typography` fields
After the 4 recipe-driven vision passes in Step 6, run the master atom:
```bash
# Read the master atom before dispatching per-role vision
cat .opencode/skills/paperforge-development/atoms/ocr-vision-audit-master.md
```
It contains the per-role data-cross-ref + quality-check methodology. Paste the relevant section into each vision subagent prompt depending on which role set the agent is reviewing.

View file

@ -0,0 +1,77 @@
# OCR Per-Role Audit Strategies
> **Use this as a quick reference** for deciding how to inspect any role.
> For actual audit work, use `scripts/audit_helpers.py` recipes + `ocr-vision-audit-templates.md`.
> Do NOT write custom Python for per-role checks — it's already built.
## Tier Classification
| Tier | Method | When |
|------|--------|------|
| A | **Vision-required** | Must dispatch `vision` subagent. Data alone insufficient. |
| B | **Data-primary, vision-confirm** | Read data first. Dispatch vision only when data flags an issue. |
| C | **Data-sufficient** | JSON analysis is enough. Vision only for ordering/layout ambiguity. |
| D | **Structural-only** | Read from block trace / document structure. No vision needed. |
## Role → Tier Mapping
### Tier A — Vision-Required
| Role | Why vision is needed |
|------|---------------------|
| `figure_asset` | Is the box covering an actual image? Are sub-panels merged? |
| `media_asset` | Is this a figure, a table, or a phantom block? |
| `figure_caption` | Is it a real caption, sub-panel label, or body mention? Where is its image? |
| `figure_caption_candidate` | Should it be confirmed or demoted? |
| `figure_inner_text` | Is it inside a figure (panel label) or separate text? |
| `table_caption` | Same as figure_caption. Cross-page? |
| `table_caption_candidate` | Should it be confirmed? |
| `table_asset` | Is this actually a table image? |
| `table_html` | Is the HTML table rendering correctly? |
| `structured_insert` | Is this a callout box or mislabeled body text? |
| `structured_insert_candidate` | Should it be confirmed? |
| `non_body_insert` | What is this element visually? |
### Tier B — Data-Primary, Vision-Confirm
| Role | Data check | When to add vision |
|------|-----------|-------------------|
| `body_paragraph` | zone=body_zone? text non-empty? | zone wrong, text empty, or two-column ambiguity |
| `section_heading` | numbering depth, heading absorption (two levels in one block) | verify visually if absorption detected |
| `subsection_heading` | same as section_heading | same |
| `sub_subsection_heading` | same | same |
| `reference_heading` | in reference_zone? before first reference_item? | transition page boundary |
| `reference_item` | in reference_zone? correct numbering pattern? | real intrusions or numbering gaps |
| `backmatter_heading` | after last reference? in backmatter_zone? | if inside reference_zone |
| `backmatter_body` | same | same |
### Tier C — Data-Sufficient
`paper_title`, `authors`, `affiliation`, `frontmatter_support`, `frontmatter_noise`,
`abstract_heading`, `abstract_body`, `keywords`, `footnote`
These are on page 1 (or page N for footnotes) at known positions with known patterns.
Read `page_001_index.json`. Vision only for ordering ambiguity or badge mislabel suspicion.
### Tier D — Structural-Only
`tail_candidate_body`, `backmatter_boundary_candidate`, `backmatter_boundary_heading`,
`ocr_raw_error`, `unknown_structural`, `page_header`, `page_footer`, `noise`
Read from `blocks.structured.jsonl` (text, span_metadata, _ocr_raw_status) or
`document_structure.json` (tail order, boundaries). No vision needed.
## Common Failure Patterns (quick reference)
| Pattern | Signal in data | Confirmation |
|---------|---------------|-------------|
| Sub-panels not merged | N asset blocks, 1 fig with < N assets | Vision: are they panels of same figure? |
| Caption sandwiched between figs | close_asset_tie with above+below | Vision: which figure owns the caption? |
| Cross-page caption | no_asset_match, prev/next page has orphan assets | Vision: is image on adjacent page? |
| Sub-panel heading as caption | figure_caption with text like "Musculoskeletal conditions..." | Vision: is this a panel heading? |
| Truncated legend as standalone | "Fig. 6" only, long caption below | Vision: composite figure with shared caption? |
| Table mislabeled as figure | media_asset with raw_label=table | Vision: is this a table or figure? |
| Body text as noise | noise with text_preview > 30 chars, not at page edge | Vision: is this body text? |
| Heading absorption | section_heading text has "2. ... 2.1. ..." | Vision: two heading levels merged? |
| Badge as authors | authors text = "HIGHLIGHTED PAPER" | Vision: is it a badge or authors? |
| Reference zone intrusion | body_paragraph in reference_zone | Vision: verify boundary |

View file

@ -0,0 +1,286 @@
# OCR Vision Audit Master — CODE vs VISION 严格分离
> **原则:** 每个检查标注来源。
> `[CODE]` = 从 JSON 文件或 PDF 元数据精确提取,无需视觉判断。
> `[VISION]` = 必须看 `page_NNN.png`,代码没有所需数据。
>
> 字体属性family、size、weight、color、italic`blocks.structured.jsonl`
> `span_metadata` / `span_signature` 中,代码可直接提取。不要用 vision 做字体检查。
---
## 0. 数据源权限
| 文件 | `[CODE]` 可读 | `[VISION]` 需要 |
|------|-------------|----------------|
| `page_NNN.png` | ❌ | ✅ |
| `page_NNN_index.json` | ✅ role, zone, bbox, text | — |
| `block_coverage_summary.json` | ✅ role, raw_label, zone, bbox | — |
| `blocks.structured.jsonl` (OCR 源) | ✅ **span_metadata (font/color/size), span_signature, style_family** | — |
| `block_review.jsonl` | ✅ truth_role, truth_zone, truth_reference_membership | — |
| `figure_table_ownership_summary.json` | ✅ matched/ambiguous/unmatched 计数 | ⚠️ 验证匹配正确性 |
| `fulltext_block_mapping_summary.json` | ✅ found_in_fulltext排除 noise 后) | — |
| `reference_span_audit.json` | ✅ inside/outside | ⚠️ 边界验证 |
| `coverage_check.json` | ✅ 100% 可靠 | — |
---
## 1. `[CODE]` 自动化可信任检查
以下从 JSON 数据直接提取,**不看图片**,结果决定性的。
### 1.1 Role 一致性 — block_review.jsonl + diff_audit.py
```yaml
输入: block_review.jsonl 的 truth_role vs _current_role
changed_blocks_after_fallback.json
方法: 字符串直接比较
信任度: 100%
输出: 每个 role mismatch 的 {block_id, pipeline_role, truth_role}
```
### 1.2 Zone 一致性 — block_review.jsonl
```yaml
输入: block_review.jsonl 的 truth_zone vs _current_zone
方法: 字符串直接比较
信任度: 100%
```
### 1.3 渲染完整性 — fulltext_block_mapping_summary.json
```yaml
输入: fulltext_block_mapping_summary.json
found_in_fulltext=false 且 role ∉ {noise, figure_inner_text, frontmatter_noise}
方法: 前80字符子在渲染全文中的存在性检查
信任度: 高(排除 noise 后),注意子串匹配微小误报可能
输出: 真正在 fulltext 中丢失的 body_paragraph / reference_item / backmatter_body 等
```
### 1.4 图/表未匹配资产 — figure_table_ownership_summary.json
```yaml
输入: figures.unmatched_asset_count, tables.unmatched_asset_count
方法: 直接计数
信任度: 100%
输出: 有未匹配资产的 paper未匹配数量
注意: 不判断为什么。vision 看具体原因。
```
### 1.5 覆盖完整性 — coverage_check.json
```yaml
输入: coverage_check.json 的 status, missing_block_ids
信任度: 100%
```
### 1.6 字体一致性 — blocks.structured.jsonl
从每个 block 的 `span_signature``span_metadata` 提取:
```yaml
输入: blocks.structured.jsonl 中所有 text block 的 span_signature
(font_family_norm, font_size_bucket, bold, italic)
方法: 按 role 分组,检查组内一致性
信任度: PDF 直接提取100% 精确
```
**具体检查项:**
```
A. 同一 role 内 font_family_norm 一致吗?
- body_paragraph 应该全是同一字体(如 MyriadPro-Light
- 如果某个 body_paragraph 的 font_family 与其他不同 → 混用字体
B. 同一 role 内 font_size_bucket 一致吗?
- 所有 body_paragraph 的 font_size_bucket 应相同
- 差异 > 1pt → 字号不一致
C. 标题层次递减正确吗?
- 按 page 分组,比较 section_heading / subsection_heading 的 font_size
- H1 > H2 > H3 在字体大小上应有明显递减
D. 同一 block 内混用字体吗?
- span_metadata 有多个 entries 且 font 不同 → 可能是加粗/斜体标记混入
- 检查是否合理(如关键词加粗)还是问题(同一字体错误嵌入)
E. 加粗/斜体使用一致吗?
- 所有 section_heading 的 bold=true
- 所有 body_paragraph 的 bold=false
```
### 1.7 排版布局 — blocks.structured.jsonl + bbox
从每个 block 的 `bbox` 坐标提取:
```yaml
输入: blocks.structured.jsonl 的 bbox [x0, y0, x1, y1]
page_width, page_height
```
**具体检查项:**
```
A. 对齐方式检测:
每页取 body_paragraph 的 x0, x1 坐标:
- 如果所有 x0 相同 → 左对齐 (ragged right)
- 如果所有 x1 相同且 x0 不同 → 右对齐
- 如果 x0 和 x1 都跨block一致 → 居中对齐?
- 如果 x0 都相同但 x1 不同(各行不同长度)+ x1 抵边 → 两端对齐(justified)
- 跨页一致吗?
B. 列检测:
每页对 body_paragraph 的 x-center 做聚类:
- 1个聚类 → 单栏
- 2个聚类 → 双栏(和 expected 一致?)
- 聚类中心 x-center 分布均匀?
C. 页边距一致性:
每页 body_paragraph 的 min(x0) 应大致相同(左页边距)
每页 body_paragraph 的 max(x1) 应大致相同(右页边距)
跨页比较左/右边距是否一致
D. 行间距检测:
同一列相邻 body_paragraph 的 y0 差值减去前一个 block 的高度
= 段间距
跨 block 段间距一致吗?
E. 孤行/孤段检测:
如果一页上某个 body_paragraph 的 height < 正常行高的1.5倍
且它在页面顶部或底部 → orphan 或 widow
```
### 1.8 引用区间完整性 — reference_span_audit.json
```yaml
输入: reference_span_audit.json 的 status, inside_block_ids
方法: status == "HOLD" → 区间未通过验证
inside_block_ids 中 role ≠ reference_item/heading → 可能 intrusion
信任度: 状态可靠intrusion 需要 vision 确认
```
---
## 2. `[VISION]` 必须看图片
以下检查代码**没有所需元数据**,必须看 `page_NNN.png`
### 2.1 图/表视觉验证
```
- 子面板覆盖:一个 figure 的 asset 列表覆盖了所有视觉上的子面板吗?
- 子面板合并2x2 的 grid 被合并为 1 个 figure 还是 4 个独立的?
- caption 位置:在图上方/下方?跨页?
- caption 语义:文字描述的内容与图上数据一致吗?(代码读不了图内容)
- 表布局HTML 表渲染正确?图片表完整无裁剪?
- 角色误标media_asset 实际上是 tablefigure_caption 实际上是 section_heading
```
### 2.2 图像质量
```
- 分辨率:模糊/锯齿/像素化?
- 仪器截屏:显微镜软件、流式细胞仪输出?
- 对比度:前景/背景区分足够?
```
### 2.3 颜色可达性(仅限图片中的颜色)
```
- 图片本身的配色(热图、荧光图等)
- 是否只依赖红/绿区分条件?
- 注意:文本颜色在 span_metadata.color 中,用代码检查
```
### 2.4 统计完整性(图中数据 vs caption 描述)
```
- 图中显示的误差棒/显著性标记与 caption 描述一致?
- N 值标注了吗?
- p 值标注与图例对应?
- 热图 colorbar 标注完整?
```
### 2.5 Chart 类型识别 + 深度分析
```
识别 chart 类型 → 路由到 paperforge/skills/paperforge/atoms/chart-reading/{TYPE}.md
然后用对应指南做深度检查。
```
### 2.6 引用区间边界验证
```
- 参考页第一页body 在 "References" 前结束了吗?
- 过渡页body 和 ref 没有交错?
- reference_zone 外的 reference_item 是否真的是引用条目?
- intrusion candidate 是真的还是 span 边界误标?
```
---
## 3. 不可信自动化结果(不用)
以下数据不作为 finding只做导航
| 数据 | 问题 | 替代用法 |
|------|------|---------|
| `same_page_boundary_error` (audit_report.json) | 纯页面角色计数,正常布局也被标 | vision 优先审这些页 |
| `object_ownership_error` (audit_report.json) | 只报 ambiguous/unresolved漏了大批量 unmatched_assets | 改为读 unmatched_asset_count |
| noise block 的 render_mapping_error | noise 不该在 fulltext 中 | 排除 noise 后看 |
| vision 做字体/颜色检查 | 不准PDF 元数据更精确 | 用 blocks.structured.jsonl |
---
## 4. 输出格式
```jsonl
{
"block_id": "p3:12",
"page": 3,
"review_status": "reviewed",
"truth_role": "figure_asset",
"truth_zone": "display_zone",
"truth_reference_membership": "outside",
"evidence": {"annotated_page": "annotated_pages/page_003.png", "method": "visual+bbox"},
"short_reason": "Fig. 2A MRI image — role and zone correct",
"vision_checks": {
"subpanels_merged": "A (2 of 2 panels matched)",
"image_quality": "OK",
"chart_type": null
}
}
```
`vision_checks` 可选,只记录 vision 实际检查的维度,未检查的用 `null`
---
## 5. 工作流程
```
1. 跑 [CODE]
diff_audit.py → role/zone mismatch 列表
fulltext_block_mapping → 真实渲染丢失(排除 noise
figure_table_ownership → unmatched_asset_count
coverage_check → 缺失 block
blocks.structured.jsonl → 字体一致性、排版异常
2. 决定 vision 目标
优先:
- code 发现的 role mismatch block
- unmatched_asset > 0 的 figure
- body/reference 在 fulltext 丢失的 block
- 参考页第一页
- figure-heavy 页
- coverage FAIL 的 page
3. 逐 block vision [VISION]
对选中 block:
看 annotated page → 确认角色/区域
如果是 figure → 子面板、质量、chart 路由
4. 输出
block_review.jsonl 只写 vision 确认过的 block
不编造未看内容
```

View file

@ -0,0 +1,251 @@
# OCR Vision Audit Templates
> **Purpose:** Structured prompts for vision agents. Each template pairs with a `--recipe` from `audit_helpers.py`.
> The agent workflow: (1) run `audit_helpers.py --recipe figure_map` to get JSON → (2) read this template → (3) dispatch vision with data + template → (4) vision reports findings.
>
> **After running these 4 templates**, read `ocr-vision-audit-master.md` for the deep quality pass:
> per-role cross-reference pathways, typography checks, chart-type routing to `paperforge/skills/paperforge/atoms/chart-reading/*.md`, figure/table quality checks.
---
## Template 1: Figure Verification
**Data source:** `audit_helpers.py --recipe figure_map --key {KEY}`
**Vision agent prompt (replace {KEY}, {DATA_JSON}, {PAGE_LIST}):**
```
You are auditing figure matching for paper {KEY}. Below is a structured summary
of every figure the pipeline detected — matched, ambiguous, and unresolved.
FIGURE DATA:
{DATA_JSON}
Annotated pages are at:
audit/{KEY}/annotated_pages/page_NNN.png (color-coded block rectangles)
audit/{KEY}/annotated_pages/page_NNN_index.json (maps label numbers to block IDs)
For EACH figure in the data, look at the corresponding annotated page(s) and answer:
### For every figure:
1. VISUAL CONFIRMATION: Find the colored rectangle(s) for this figure on the annotated page.
- The label numbers on the PNG correspond to `block_id` in the data.
- The index JSON maps label_number → block_id, role, bbox.
- TRACE: Find the caption block first (its label number), then find each asset block.
2. ASSET COVERAGE: Does the set of colored boxes cover ALL visible sub-panels of this figure?
- Count how many sub-panels the figure has (from the colored boxes on the page).
- Compare with `assets` list in the data. Are any sub-panels MISSING? Are any EXTRA?
- If data says N assets but visually you see M sub-panels: report the discrepancy.
3. CAPTION CORRECTNESS: Is the caption text (shown in the data) visually the correct caption for this figure?
- Is the caption block positioned correctly relative to the figure (below, above, or side-by-side)?
- Does the caption text match what you see on the page?
- Is it a REAL caption (description of the figure), not a section heading or body text?
4. SUB-PANEL MERGING: Are sub-panels of the SAME figure correctly grouped?
- If the page has a 2x2 grid of images with ONE shared caption, they should be ONE figure.
- If the data shows them as separate figures: that's a merge failure.
- If the data shows them as one figure with N assets: check that N matches visual sub-panels.
5. CROSS-PAGE CHECK: If a figure's caption is on a different page than its assets:
- Is this correct (caption on page N+1, image on page N)? Or is it a mismatch?
- Look at both pages to confirm.
6. ROLE MISLABELS:
- Is any `figure_caption` block actually a section heading? (e.g., "Figure 3. In vitro evaluation..." at the top of a new section)
- Is any `figure_asset` / `media_asset` block actually a table?
- Is any `figure_caption` actually just a sub-panel label ("Fig. 6" only, no description)?
### For each figure, report:
```
Fig {N} — page {P} — status: {OK / PROBLEM}
Issue: {specific problem description}
Evidence: {what you see on the annotated page that supports this}
```
### After checking all figures, provide a summary:
```
Total figures: {N}
OK: {M}
Problems found: {K}
Problem details:
Fig X: [one-line issue]
...
```
### Common patterns to watch for:
- **Truncated label as standalone figure:** "Fig. 6" with no description text, next to a long caption block below → these should be ONE figure with the long text as caption.
- **Multi-panel grid as separate figures:** 4 images in a 2x2 grid, each with "Fig. N" label, one shared caption below → should be ONE composite figure.
- **Caption between two figures:** "Fig. 2" caption sandwiched between Fig 2 image above and Fig 3 image below → caption belongs to the image ABOVE.
- **Empty/phantom blocks in figure area:** A colored box with no visible content inside → the block detection created a ghost.
- **Cross-page orphan:** Caption on page N, image on page N-1 → normal for preproofs.
```
---
## Template 2: Frontmatter Verification
**Data source:** `audit_helpers.py --recipe frontmatter_map --key {KEY}`
**Vision agent prompt:**
```
You are auditing page 1 (frontmatter) for paper {KEY}.
FRONTMATTER DATA:
{DATA_JSON}
Annotated page: audit/{KEY}/annotated_pages/page_001.png
The data lists every block on page 1, top-to-bottom, with its pipeline-assigned role.
Your job: verify each block's role against what you VISUALLY see on the page.
### For each block, answer:
1. ROLE CHECK: Is the assigned role correct?
- paper_title: should be the LARGEST text near the top, centered or left-aligned.
- authors: multiple names, usually below the title, smaller font.
- affiliation: institutional addresses, below authors.
- abstract_heading / abstract_body: "Abstract" label + paragraph text.
- frontmatter_noise: journal name, article type badge, publisher strip — small text at page edges.
- frontmatter_support: correspondence, DOI, dates.
2. BADGE CHECK: Is there a "HIGHLIGHTED PAPER" or "RESEARCH ARTICLE" badge?
- If the data labels it as authors → WRONG.
- If the data labels it as noise → correct.
3. MISSING ROLES: Are any expected roles missing?
- If data says `missing_expected: ["paper_title"]` → check if the title is absorbed into another block.
- If data says `missing_expected: ["abstract_heading"]` → check if the abstract exists but is labeled as body_paragraph.
4. ORDERING: The blocks in the data are sorted top-to-bottom. Is this order visually correct?
- Expected: title → authors → affiliation → (badges/noise mixed in) → abstract → keywords → body text.
- If the order looks wrong visually, report the correct order.
### Report format:
```
Page 1 — {paper_title}
Roles OK: {list of correct roles}
Roles WRONG:
p1:{block_id}: labeled as {role}, should be {correct_role}
Missing: {list}
Badge issue: {yes/no + detail}
```
```
---
## Template 3: Reference Verification
**Data source:** `audit_helpers.py --recipe reference_map --key {KEY}`
**Vision agent prompt:**
```
You are auditing reference completeness for paper {KEY}.
REFERENCE DATA:
{DATA_JSON}
Annotated pages are at audit/{KEY}/annotated_pages/. Focus on:
- The first reference page (where references begin)
- The last reference page (where references end)
- Any "transition_pages" listed in the data
### Check these specific things:
1. REFERENCE BOUNDARY: On the first reference page, is there a clean boundary between body text and references?
- Look for the "References" heading (colored as reference_heading).
- Body text (green) should STOP before this heading.
- If body paragraphs appear AFTER the "References" heading → intrusion.
2. INTRUSIONS: The data lists "real_intrusions" — body_paragraph or backmatter blocks inside the reference zone.
- If empty: no intrusions detected.
- If not empty: look at the specific pages listed. Are these REALLY inside the reference zone?
- Note: noise blocks (gray) between reference items are NORMAL (page numbers, headers).
3. ZONE GAPS: The data lists references not in `reference_zone`.
- Look at the listed pages. Are these reference items that should be in reference_zone?
- If ALL refs on a page are in the wrong zone: the zone assignment failed for that page.
4. TRANSITION PAGES: Pages where body and references co-exist.
- Look at the page: is the visual boundary clean?
- Is there body text that should have ended before the references?
5. REFERENCE NUMBERING: The data lists missing or duplicate reference numbers.
- This is a data-derived check. No visual verification needed.
- Missing numbers may indicate references absorbed into body text → check visually if the missing references appear as body paragraphs.
### Report format:
```
References — {total_references} total
Span: {start} → {end}
Boundary clean: {yes/no}
Real intrusions: {count or "none"}
Zone issues: {count or "none"}
Numbering gaps: {list or "none"}
Visual findings:
[page-specific observations]
```
```
---
## Template 4: Body Text Verification
**Data source:** `audit_helpers.py --recipe body_text_map --key {KEY}`
**Vision agent prompt:**
```
You are auditing body text cleanliness for paper {KEY}.
BODY TEXT DATA:
{DATA_JSON}
The data lists, per page, every body_paragraph, noise, unknown_structural, and
other content block with their bboxes, zones, and text previews.
Annotated pages at audit/{KEY}/annotated_pages/page_NNN.png
### For pages flagged in the data:
1. NOISE IN CONTENT AREA: Blocks with `flag: text_in_content_area` or `flag: empty_in_content_area`.
- Look at the specific page. Is the noise block actually sitting in body text?
- Is it page furniture (page number, header) that happens to have non-edge coordinates?
- Is it a phantom (empty box with no visible content)?
2. BODY TEXT WITH EMPTY TEXT: Body_paragraph blocks with `flag: empty_text_*`.
- Look at the bbox on the annotated page. Is there visible text at this position?
- If yes: OCR missed the text → PDF backfill needed.
- If no: the block is a phantom that should be removed.
3. LARGE GAPS between body paragraphs (gap_px > 100).
- Look at what's in the gap (listed in `blocks_in_gap`).
- Is there supposed to be a figure, heading, or table here?
- Is it a two-column layout where the gap is the right-column text?
4. TWO-COLUMN SUSPICION: If a page has body paragraphs with widely different x-centers.
- Look at the page layout. Is it genuinely two-column?
- Do the left and right columns each have coherent reading order?
### Report format:
```
Body text — {page_count} pages checked
Pages with noise in content: {list}
Pages with empty body text: {list}
Pages with large gaps: {list}
Per-page findings:
Page {N}: [specific observations]
```

View file

@ -36,22 +36,72 @@ Inputs:
Materialize helper outputs under `audit/<paper_key>/` via `../scripts/ocr_truth_audit.py`, but treat them as accelerators only, not truth.
6. Inspect truth from evidence.
Start with the overview pages (`annotated_pages/page_*.png`). Each page shows every block as a numbered rectangle, color-coded by the **pipeline's final rendered role** (after gate + normalize, the same role used in `fulltext.md`). Each of the 28 roles has a distinct color, grouped by semantic family:
- dark blue family: `paper_title` `authors` `affiliation` `frontmatter_support` `frontmatter_noise`
- purple family: `abstract_heading` `abstract_body`
- orange/red family: `section_heading` `subsection_heading` `sub_subsection_heading`
The audit is split into **five phases**. Phase 6a-6d use the 4 recipes + 4 templates.
Phase **6e** is the deep quality pass using the master atom.
Work through them in this order.
Each domain has a **data map** (run the recipe) and a **vision template** (read the atom).
**6a. Figure verification (most error-prone — do this first).**
```bash
python .opencode/skills/paperforge-development/scripts/audit_helpers.py --recipe figure_map --key {KEY}
```
This prints a JSON listing every figure: caption page/text, asset blocks with bboxes,
match status, candidates, panel labels, cross-page flags.
Then read `../atoms/ocr-vision-audit-templates.md` → "Template 1: Figure Verification".
Dispatch a `vision` subagent with the figure_map JSON + that prompt template.
The vision agent checks: asset coverage, caption correctness, sub-panel merging,
cross-page pairing, role mislabels. Reports discrepancies between pipeline data and
visual truth.
**6b. Frontmatter verification.**
```bash
python .opencode/skills/paperforge-development/scripts/audit_helpers.py --recipe frontmatter_map --key {KEY}
```
Then read `../atoms/ocr-vision-audit-templates.md` → "Template 2: Frontmatter Verification".
Dispatch `vision` subagent with the frontmatter JSON + template.
Checks: role correctness on page 1, badge mislabel, missing roles, ordering.
**6c. Reference verification.**
```bash
python .opencode/skills/paperforge-development/scripts/audit_helpers.py --recipe reference_map --key {KEY}
```
Then read `../atoms/ocr-vision-audit-templates.md` → "Template 3: Reference Verification".
Dispatch `vision` subagent with the reference JSON + template.
Checks: reference boundary, real intrusions, zone gaps, transition pages.
**6d. Body text verification (only flagged pages need vision).**
```bash
python .opencode/skills/paperforge-development/scripts/audit_helpers.py --recipe body_text_map --key {KEY}
```
Then read `../atoms/ocr-vision-audit-templates.md` → "Template 4: Body Text Verification".
Most pages pass on data alone. Dispatch `vision` only for pages with
flagged gaps, noise-in-content, or empty body text blocks.
**6e. Deep quality + typography audit (new — extends all 4 domains).**
After the 4 recipe-driven vision passes, read `../atoms/ocr-vision-audit-master.md`.
This atom provides per-role cross-reference pathways, typography checks,
figure/table quality checks, chart-type routing to the chart-reading atoms,
and full-page layout audit.
For each block that passed the recipe check, dispatch an additional vision pass
if the block is Tier A (figure/table/structured) or if the page has typography flags.
The master atom tells you which data files to cross-reference and what to look for.
**Color reference for annotated pages:** Each role has a distinct color:
- dark blue: `paper_title` `authors` `affiliation` `frontmatter_*`
- purple: `abstract_heading` `abstract_body`
- orange/red: `section_heading` `subsection_heading` `sub_subsection_heading`
- green: `body_paragraph`
- red family: `reference_heading` `reference_item`
- purple-brown family: `backmatter_heading` `backmatter_body` `backmatter_boundary_candidate`
- amber/gold family: `media_asset` `figure_caption` `figure_caption_candidate` `figure_inner_text` `table_caption` `table_caption_candidate`
- teal family: `structured_insert` `structured_insert_candidate` `non_body_insert`
- gray family: `noise` `footnote` `unknown_structural`
- red: `reference_heading` `reference_item`
- purple-brown: `backmatter_*`
- amber/gold: `figure_asset` `media_asset` `figure_caption*` `table_caption*` `figure_inner_text`
- teal: `structured_insert*` `non_body_insert`
- gray: `noise` `footnote` `unknown_structural`
The color tells you what the pipeline decided. Your job is to judge whether that decision matches visual truth.
The companion index file (`annotated_pages/page_*_index.json`) maps each label number to its full `block_id`, `role`, `zone`, `category`, `text_preview`, and `bbox`. Use the index to look up what any numbered block currently is, then judge its truth visually from the overview page.
Cross-reference against `block_trace`, document-structure artifacts, and rendered `fulltext` only after forming an initial visual judgment.
The annotated pages show what the pipeline decided. Your job is to judge whether
that matches visual truth. The companion index (`page_*_index.json`) maps label
numbers to block IDs for cross-reference.
7. Write audit outputs.
Produce the required reports and summaries using `../scripts/ocr_truth_audit.py` plus the schema in `../atoms/ocr-audit-report-schema.md`.
@ -71,6 +121,13 @@ Inputs:
Use the page index to find a block's current role/zone before judging. Color category helps orient: red blocks are reference candidates, purple are backmatter candidates, etc.
For deep quality checks, see `../atoms/ocr-vision-audit-master.md` section 3 (chart quality),
section 4 (page typography), and section 2 (per-role analysis with quality dimensions).
8.5. Record quality findings in block_review.jsonl.
Extend each block_review.jsonl entry with optional fields `quality_checks` and `page_typography`
as described in `../atoms/ocr-vision-audit-master.md` section 5. Use `null` for unchecked dimensions.
9. Verify review coverage.
Run `../scripts/verify_review_coverage.py` against the paper audit directory. If required blocks are missing for the chosen mode, the audit is incomplete.

View file

@ -1388,3 +1388,215 @@ For each paper: rebuilt pipeline, generated annotated pages, performed visual bl
**Result:** Figure grouping is less dependent on tidy layouts while preserving the no-page-swallow guardrail.
**Validation:** Rebuilt residual and unseen figure-heavy papers after the change; no new failure family introduced.
### 11.6 Reference Zone And Ownership Hardening With Untouched-Paper Regressions (2026-06-20)
**Problem:** Untouched truth-audit papers still showed reference intrusion and ownership fragmentation on irregular layouts.
**Root cause:** Page continuity facts were too weakly shared across reference and ownership consumers.
**Fix:** Added shared layout facts, hardened practical reference corridor containment, introduced journal-abbreviation-backed reference-style support, added bridge-aware display continuity for figure/table ownership, and limited PDF text fallback to local reference-entry repair.
**Result:** Reference handling is more containment-oriented on mixed pages; ownership tolerates bridgeable empty gaps without reopening page swallow.
**Test status:** Targeted OCR unit tests and untouched-paper regressions pass.
### 11.7 Figure Matching Hardening: Inline-Mention, Table-Label, Sub-Panel, Cross-Page, Legend-Bundle (2026-06-20)
**Problem:** 10-paper audit showed figure matching at ~75%. Dominant failure modes:
(1) `_looks_like_inline_figure_mention` used text-content heuristics (verb lists, word-count thresholds) that falsely flagged long captions as body prose — caption_score dropped to 0.2, figures routed to unmatched_legends.
(2) OCR labeled tabular sub-panel grids as `table``media_asset` role → excluded from figure-matching candidate pool.
(3) Sub-panels fragmented into separate blocks across pages, never merged into parent figure clusters.
(4) Cross-page figures (caption on page N+1, assets on N) unmatched by same-page-only matcher.
(5) Preproof legend bundling (all captions on one page, figures on later pages) undetected.
**Root cause (inline mention):** `_looks_like_inline_figure_mention` line 91 used `len(words) >= 10` as gate for body-prose detection. Long VFS figure captions (30+ words) hit this, caption_score dropped below 0.4 threshold.
**Root cause (table label):** `build_figure_inventory` excluded `media_asset` with `raw_label="table"` from both `assets` collection (line 1000) and `_media_clusters` (line 432). KZP6FB4Y Fig 2 is a tabular sub-panel grid labeled `table` by PaddleOCR.
**Root cause (sub-panel merge):** `_grow_region_from_seed` had gap threshold of `page_width * 0.03` = 36px. Sub-panels ~50px apart in multi-panel figures couldn't merge.
**Root cause (cross-page):** `_allow_previous_page_sequential_match` required `zone == "post_reference_backmatter_zone"`. 28ALPCY7 Fig 6 caption in `display_zone` blocked. Also `future_page_asset` had no page-distance guard.
**Root cause (legend bundle):** PZ8B59K4 has "Figure Legends" page (p16) with 5 captions, figures on p19-23. Dedup logic preferred body-mention over `figure_caption` role, losing Fig 3. Sequential fallback matched across 3-page gaps.
**Fixes:**
- `_looks_like_inline_figure_mention`: replaced text-content heuristics with zone+style signals. Functions with `figure_caption` role or `display_zone + legend_like` style return False directly.
- `ocr_figures.py`: added "table" to `media_asset` raw_label filter in 3 places (assets collection, `_build_candidate_figure_groups_from_assets`, `_media_clusters`).
- `_grow_region_from_seed`: increased gap threshold from 36px to `max(page_width * 0.08, 40)` = 96px.
- `close_asset_tie` and `is_legend_only` both filter `figure_caption`/`figure_caption_candidate` with `fig_num is None` to `unmatched_legends`.
- `_allow_previous_page_sequential_match`: added `display_zone` to accepted zones.
- `future_page_asset`: restricted to `cp+1` only (no multi-page gaps).
- Legend-bundle detection: 3+ figure captions on same page with 0 assets → 1:1 page-order match to subsequent pure-figure pages.
- Dedup: prefer `figure_caption` role when both entries have no same-page assets.
- `marker_signature` fallback: when `_extract_figure_number` returns None from empty text, check `marker_signature.type == "figure_number"`.
- `ocr_pdf_spans.py`: set `role = "body_paragraph"` on backfilled blocks. `_refresh_artifacts` runs backfill before `rebuild_from_raw`.
**Test status:** 341 passed (97 figure + 205 document + 69 roles + 11 PDF + 34 tables). 2 tests updated to match new inline-mention behavior (text-only blocks without zone/style now match instead of being unmatched).
**Result:** Figure matching 76/83 = 92% across 10 papers. VFS8CBW2: 3→8 matched. Remaining: RKSLQRIM 5 ambiguous (pre-proof cross-page), 6DIINFHX 1 ambiguous (close_asset_tie).
### 11.8 OCR Body Text Recovery: PDF Backfill For OCR-Missed Blocks (2026-06-20)
**Problem:** PaddleOCR detects layout regions but fails to extract text on some two-column pages. Span metadata exists (88 spans) but all have `text=""`. Blocks end up as `unknown_structural` with `text=[]`.
**Root cause:** Upstream OCR model limitation — column boundary regions have detectable bboxes but the OCR engine produces no text output.
**Fix:** `ocr_pdf_spans.py` backfills text from PDF embedded text layer using `fitz.get_text("words", clip=bbox)`. Sets `_ocr_raw_status = "missing_text_recovered"` and `role = "body_paragraph"` on recovered blocks. `_refresh_artifacts` in audit runs backfill BEFORE `rebuild_from_raw` so structured block builder sees recovered text.
**Result:** KIX7SKXQ p3:7 recovered 323 chars ("Studying and understanding EnEF..."), p1:17 recovered 373 chars. All 4 papers audited show 0 empty unknown_structural blocks.
### 11.9 Figure Matching Known Issue: Shared-Caption Multi-Panel Figures (2026-06-20)
**Problem:** Paper 6QNRHRKX (JBJS 1970) has a 2x2 X-ray grid with individual "Fig. N" sub-panel labels (N=4,5,6,7) and a single shared caption below the entire grid. Each "Fig. N" label is treated as a separate truncated legend, creating 4 ambiguous figures with 4-7 candidates each. The real caption (block p3:12, `figure_caption_candidate`) can't match all 4 images simultaneously.
**Scope:** Rare pre-1990 format. Only observed in 6QNRHRKX across 36 audited papers.
**Current mitigation:** Truncated legends filtered when 2+ on same page. Short (<80 char) captions with fig_num=None go to unmatched_legends. Long shared caption remains as is.
**Targeted fix (deferred):** Detect N truncated legends sharing one long caption on same page → merge all N images as sub-panels of one composite figure with the long caption as owner. Trigger: >=2 \( `_TRUNCATED_LEGEND_ONLY_PATTERN`\) entries on same page + >=1 `figure_caption_candidate` with text >80 chars spanning the full grid width.
### 11.10 Figure Matching Known Issue: Cross-Page Preproof (RKSLQRIM) (2026-06-20)
**Problem:** RKSLQRIM (preproof) has 5 remaining ambiguous figures. Fig 10 caption on p28 has 0 same-page candidates. Sub-figure panel labels (A, B, C) on p15, p19, p20, p24 are misclassified as `figure_caption` with empty text. These block types: `display_zone` + `legend_like` + `raw_label=figure_title` — the pipeline sees a legend with empty text and a marker_signature with the figure number, but the figure assets are on different pages.
**Scope:** Preproof papers with cross-page figure layouts. Affects ~5-7 papers across 36.
**Current mitigation:** marker_signature fallback extracts figure numbers from empty-text blocks. These now get `single_unconfirmed_match` with 1 candidate instead of `no_asset_match`. But cross-page matching still fails for pages separated by body text.
**Targeted fix (deferred):** Extend sequential fallback or legend-bundle to handle cross-page gaps larger than 1 page when intervening pages have no competing figure captions.
### 11.11 Figure Matching: close_asset_tie Above-Preference + adjacent_x Scoring (2026-06-20)
**Problem 1 (close_asset_tie):** When a figure caption is sandwiched between two asset groups (one above, one below), both scored identically and the algorithm couldn't break the tie. This produced `close_asset_tie` ambiguous entries for standard layouts where the caption sits below the figure image and above the next figure's image.
**Fix:** When `close_asset_tie` fires with both "above" and "below" sides, prefer the above-only candidates (standard: image → caption stacked below). Falls back to close_asset_tie only when no above-only candidates exist.
**Result:** 2AGGSMVQ Fig2+Fig5 fixed (4/6→6/6), 2H8MZ27H Fig1 fixed (5/6→6/6). 97 figure tests pass.
**Problem 2 (side-by-side caption):** Two-column journals (JSES International, Springer CORR) place figure captions in the left column with the image in the right column at the same y-band. `score_figure_match` had zero `x_overlap` for this layout because bounding boxes don't intersect horizontally. The decision gate at `score >= 0.6 AND (has_x_overlap OR ...)` blocked the match because `has_x_overlap=False` and `contextual_support=False`.
**Fix:** Added `adjacent_x` signal to `ocr_scores.py:score_figure_match`: when horizontal gap < 80px AND < 30% of narrower bbox width, treat as horizontal adjacency set `has_x_overlap=True` and add +0.20 score. This is a side-by-side layout detection, not a column segmentation, so it doesn't require stable page-level column boundaries.
**Result:** 4DU8LEH2 Fig1 (JSES, caption-left image-right) fixed: 3/4→4/4. 4KCHGV2Z Fig2 (Springer CORR, same pattern) fixed. 302 tests pass.
### 11.12 Figure Matching Known Issue: Multi-Column Figure Over-Merge (2026-06-20)
**Problem:** 3FDT9652 (JSES International, 2-column journal). Page 3 has Fig 2 (left column) + Fig 3 (right column) at the same y-band. The `page_assets` / `_grow_region_from_seed` logic merges both images into one cluster because they share y-ranges, even though they belong to different figures in different columns. This causes Fig 2 to steal Fig 3's image, Fig 3 to steal Fig 4's images below, and Fig 4 to go ambiguous (0 assets left). All 6 figures on pages 2-5 are affected.
**Root cause:** Matching uses vertical proximity only, without column-boundary awareness. On multi-column pages, figures in different columns at the same y get incorrectly merged. The `_grow_region_from_seed` function's `adjacent_right` check (gap ≤ 96px) happily merges across column gutters.
**Why not fix with column segmentation:** Column gutter positions vary by journal (JSES ~750px, Springer ~650px, 3-column layouts ~450px/750px). A hardcoded threshold regresses other papers. Multi-column layouts are also relatively rare in the current collection (1 of 37 normal papers).
**Scope:** Affects 3FDT9652 specifically. Not observed in the other 36 normal papers.
**Targeted fix (deferred):** Requires "caption-as-boundary" matching: when N captions exist on a page with N asset clusters, match by reading-order proximity rather than all-page-assets clustering. Each caption should act as a firewall that prevents assets from being claimed by captions above it. Alternatively, detect multi-caption pages and disable `page_assets` groups (which merges all page assets into one cluster) for those pages, relying on `same_row_*` + region-grown groups only.
---
## 9.3 Figure Merge Root-Cause Analysis (2026-06-21)
### The problem
Multi-panel figures on complex layouts (SAN9AYVR, 2GN9LMCW Fig 4) fail to merge all sub-panels. Unmatched assets remain. The 4-direction growth + scoring bonus fixes didn't solve the core issue.
### Why human vision always merges them
A human looking at a page sees:
1. A cluster of images close together → ONE perceptual group
2. ONE legend/caption for that cluster → confirms the group
3. Gaps between images in the same figure < gaps to the next figure/text
4. The cluster forms a rectangular bounding box
Result: the human **always** merges, no competition.
### Why the algorithm fails
The grouping has 3 competing layers:
| Layer | What it creates | Why it fails |
|-------|----------------|-------------|
| `single_asset` (line 514) | 1 asset = 1 group | ALWAYS created, competes with merged groups |
| `same_row_pair/triple` (line 528) | Consecutive same-row assets | Misses 2x2 grids, 3x2 layouts, irregular shapes |
| `region_grown` (line 553) | Greedy seed-growth | First seed wins, no backtracking, eats adjacent regardless of actual figure boundary |
**Root cause:** The algorithm treats grouping as a LOCAL greedy problem when it should be a GLOBAL clustering problem.
A human sees ALL assets, computes global distances, and forms clusters. The algorithm:
1. Generates overlapping groups (same asset can be in single_asset, same_row, AND region_grown)
2. Makes them COMPETE via scoring (single_asset vs merged group for the same legend)
3. The scoring favors closeness (single asset close to caption wins over merged group with larger centroid distance)
**Fix direction:** Replace greedy competition with global spatial clustering.
### Edge-case analysis: risks and fallbacks
The simple clustering approach (group all assets within a distance threshold) fails on multiple real layouts. Here's a complete risk matrix:
| # | Scenario | Risk | How to handle | Fallback if still wrong |
|---|----------|------|---------------|------------------------|
| 1 | **One page, 2+ separate figures, each with its own caption** | Clustering merges them into 1 group | **Caption-as-boundary**: count legends on the page. If N legends == 1 → one cluster. If N legends > 1 → split assets by nearest caption y-position. Each legend "owns" the assets in its y-band. | Fall through to single_asset matching: if a cluster has multiple legends and splitting fails, treat each asset as its own group |
| 2 | **Cross-page figure (preprint, figure floats)** | Per-page clustering won't see assets on adjacent pages | Keep existing sequential fallback (`_expand_matched_assets_locally`). After matching per-page clusters, check cp-1, cp, cp+1 for orphan assets. | If the caption is on page N but ALL assets are on page N+1, the cluster on N is empty → sequential fallback claims assets on N+1 |
| 3 | **Old journal (dense, small figures, tight spacing)** | Distance threshold too generous → merge unrelated figures | Check for **text separators** between asset groups. If body_paragraph / section_heading blocks exist in the vertical gap between two asset clusters, don't merge across the separator. | Tight spacing not a problem if text separators are respected |
| 4 | **Irregular layout (1 tall panel left + 3 small panels right)** | Large horizontal gap > threshold → left panel and right stack are separate clusters | Use **vertical-overlap signal**: if two asset groups share significant y-overlap (>= 50% of the shorter group's height), merge them even if horizontal gap is large. This is how humans see them as one figure. | If vertical-overlap approach over-merges, fall back to pure distance clustering |
| 5 | **Embedded caption inside figure** | Caption block exists inside asset cluster bbox (e.g., panel labels, axis text) | This is fine — we cluster ASSETS (figure_asset / media_asset), not all blocks. Text blocks are ignored. | N/A |
| 6 | **Figure + table on same page** | Table assets merge with figure assets | Split by type BEFORE clustering: figure_asset → one cluster set, table_asset → another. Legends also split by type (figure_caption vs table_caption). | If type labels are wrong (table mislabeled as figure), the split won't help. Need type-independent position-only fallback. |
| 7 | **Multi-column layout (figures in different columns at same y)** | Assets in left column figure merge with right column figure | **Caption-as-boundary**: 2+ legends on the page → split assets by nearest caption y. Each legend claims the assets below it until the next legend. Text separators (column gutters are not text separators, but section headings/subheadings in each column ARE). | If split by y fails (same y, different columns), fall through to single_asset matching — safe but unmerged |
| 8 | **Orphan single-panel figure** | 1 asset, 1 legend on the page, not near any other asset | A cluster of 1 asset is still a valid cluster. Match legend → single-panel figure. Perfectly fine. | N/A |
| 9 | **Figures separated by section heading or body text** | Text acts as visual separator but algorithm ignores text | **Text separator detection**: check the gap between two asset clusters. If any text block (section_heading, body_paragraph, backmatter_heading) exists in that gap, it's a separator. Don't merge across separators. | If text detection fails (empty block, wrong classification), fall back to distance-only clustering |
| 10 | **Very large multi-panel figure (>10 panels)** | Panels spread across full page, gaps may exceed threshold | Use **permissive distance threshold** but constrain by bounding-box aspect. A figure spanning the full page width is normal; threshold should be proportional to the cluster's growing bbox, not a fixed % of page width. | If still split, the sequential expansion (existing `_expand_matched_assets_locally`) merges remaining orphans |
### Complete proposed algorithm
```
For each page:
1. Collect figure assets on this page
(figure_asset + media_asset with image/chart/figure raw_label)
Exclude: non_body_media, table assets
2. Remove single_asset groups from consideration
3. Cluster assets by distance:
- For each pair of assets:
- Check horizontal gap < 12% page_width
- Check vertical gap < 8% page_height
- Check NO text separator (body_paragraph, section_heading)
in the vertical gap between them
- Form clusters via union-find on connected pairs
4. For irregular layouts: if two clusters share >= 50% y-overlap,
merge them even if horizontal gap exceeds threshold
5. Each cluster → ONE candidate group (no single_asset option for
any asset inside a cluster)
6. Count legends on this page:
- 0 legends → orphan cluster, pass to cross-page fallback
- 1 legend → auto-match (no scoring competition)
- N legends → split: assign each asset to nearest caption
by y-distance (each legend claims the vertical band below it)
7. Match: legend → cluster → accept.
No scoring competition. If the cluster exists and the legend
is on the same page, they belong together.
8. Single assets NOT in any cluster → treated as individual
candidate groups (backward compatible with single-panel figs).
```
### Changes to existing code
1. `_build_candidate_figure_groups_from_assets`: Replace single_asset/same_row/page_assets/region_growth with clustering
2. `_score_legend_to_group`: Remove single_asset path. Cluster groups auto-match at high confidence.
3. No change to `_expand_matched_assets_locally` (cross-page fallback remains)
4. No change to dedup (separate issue)
5. Column detection: new helper function
### Implementation risk summary
| Risk | Severity | Mitigation |
|------|----------|-----------|
| Over-merge (separate figures merged) | Medium | Caption-as-boundary split, text separator detection |
| Under-merge (figure split into pieces) | Low | Existing cross-page fallback collects orphans |
| Performance regression on large pages | Low | Union-find is O(n log n), same as current |
| Column detection false positive | Low | Column mode is additive — assets still cluster within each column |
---

View file

@ -0,0 +1,376 @@
# Global Distance Clustering for Figure Merge
> **Date:** 2026-06-21
> **Status:** draft for review
> **Scope:** Replace greedy region-growing figure merge with global distance-based clustering + caption-as-boundary fallback
> **Supersedes:** `2026-06-20-region-growing-figure-merge-design.md`
---
## 1. Why region-growing failed
The 2026-06-20 spec introduced `_grow_region_from_seed`: starting from one asset, greedily absorb neighbors right and down. It was extended to 4 directions (left, up, right, down) but the core problem remains:
| Problem | Why | Consequence |
|---------|-----|-------------|
| **First seed wins** | Assets are sorted y-x; the top-left seed absorbs everything it can reach | Remaining seeds get fewer assets → partial merges |
| **No backtracking** | Once absorbed, an asset is `seen_growth_ids` and can't join another group | Wrong merges persist |
| **Overlapping candidates** | An asset exists in single_asset + same_row + region_grown groups simultaneously | Groups compete via scoring; the closest asset wins, merged group loses |
| **Local greed, not global view** | Each seed only sees immediate neighbors, not the whole page layout | 2x2 grids, irregular layouts, and cross-column figures all fail |
**Concrete failure (SAN9AYVR Figure 23, page 50):**
A large multi-panel figure with panels a-h spanning the page. Region growth from the top-left seed absorbs some panels, eats across the page, but misses panels in the lower-right because they're more than one "gap" away from any grown boundary. Those missed panels become unmatched assets.
---
## 2. Design: distance-based clustering
### Core principle
On a given page, ASSETS THAT ARE CLOSE TOGETHER WITH NO TEXT SEPARATOR BETWEEN THEM FORM ONE FIGURE.
This is how human vision works: global spatial proximity + text boundaries = perceptual groups.
### Algorithm (8 steps)
```
Step 1 — Collect figure assets on the page
input: all blocks on a page
filter: role=figure_asset OR (role=media_asset AND raw_label∈{image,chart,figure})
exclude: _non_body_media, table assets, non_body_insert
Step 2 — Remove single_asset from candidate generation
Assets that belong to a cluster must NOT also exist as standalone candidates.
Only unclustered single assets get standalone groups.
Step 3 — Pairwise distance clustering
For each pair of assets (A, B) on the same page:
- horizontal_gap = max(0, left_b - right_a) [right edge of leftmost to left edge of rightmost]
- vertical_gap = max(0, top_b - bottom_a) [bottom of upper to top of lower]
- candidate_for_merge = true IF:
horizontal_gap <= max(page_width * 0.12, 40)
AND vertical_gap <= max(page_height * 0.08, 40)
- BUT: if a text block (body_paragraph, section_heading, subsection_heading)
with non-empty text exists in the bounding rectangle between A and B,
candidate_for_merge = false (text separator)
Union-find: connect all candidate pairs into clusters.
Step 4 — Irregular layout merge
For each pair of clusters (C1, C2):
y_overlap = overlap of C1's y-range and C2's y-range
shorter_height = min(height of C1, height of C2)
if y_overlap >= shorter_height * 0.5:
merge C1 and C2 even if horizontal_gap exceeds threshold
(Rationale: 1 tall left panel + 3 small right panels form one figure)
Step 5 — Generate candidate groups
Each cluster → ONE candidate group entry
cluster_bbox = union of all asset bboxes in the cluster
group_type = "distance_cluster"
evidence = ["same_page", "distance_clustered"]
Step 6 — Count legends on the page
legends = blocks with role=figure_caption on this page
n_legends = len(legends)
Step 7 — Match legends to clusters
Case n_legends == 0:
→ orphan clusters, pass to _expand_matched_assets_locally (existing cross-page fallback)
Case n_legends == 1:
→ auto-match legend to cluster. score = 0.85. decision = "matched"
No scoring competition.
Case n_legends >= 2:
→ split: sort legends by y-position. Sort clusters by y-position.
Assign each cluster to the nearest legend.
If multiple clusters map to the same legend → merge them.
If any legend has no cluster → pass to single_asset fallback.
Step 8 — Single assets not in any cluster
→ treated as individual candidate groups (backward compatible)
→ matched via existing score_figure_match
```
### Threshold rationale
The gap thresholds (12% page width, 8% page height, min 40px) match the existing thresholds used in `same_row_pair` and `_grow_region_from_seed`. They've been tested on 37 normal papers without false page-swallow. No new threshold tuning needed.
The min 40px floor prevents over-merging on small figures in dense layouts.
### Text separator detection
```python
def _has_text_separator(a: dict, b: dict, all_blocks: list[dict]) -> bool:
"""Check if any text block exists in the bounding rect between assets a and b."""
ax1, ay1, ax2, ay2 = a["bbox"]
bx1, by1, bx2, by2 = b["bbox"]
# bounding rect of both assets
rect = [min(ax1, bx1), min(ay1, by1), max(ax2, bx2), max(ay2, by2)]
for block in all_blocks:
role = block.get("role", "")
if role not in ("body_paragraph", "section_heading", "subsection_heading"):
continue
txt = str(block.get("text", "") or "").strip()
if not txt:
continue
bx1, by1, bx2, by2 = block["bbox"]
# text block INSIDE the bounding rect AND BETWEEN the two assets vertically
if (bx1 > rect[0] and bx2 < rect[2] and
by1 > min(ay2, by2) and by2 < max(ay1, by1)):
return True
return False
```
---
## 3. Edge-case matrix with fallbacks
All 10 scenarios from the PROJECT-MANAGEMENT analysis, with concrete handling:
| # | Scenario | Detection | Handling | Fallback |
|---|----------|-----------|----------|----------|
| 1 | One page, 2+ separate figures | n_legends >= 2 | Split by y-proximity | Per-asset single_agent scoring |
| 2 | Cross-page figure | n_legends == 0 after per-page clustering | Move to `_expand_matched_assets_locally` | Existing sequential fallback |
| 3 | Dense old-journal layout | Text separator in gap | Don't merge across separator | Tight spacing with no text → distance threshold allows merge |
| 4 | Irregular layout (1 tall + 3 small) | y_overlap >= 50 | Force merge across x-gap | If over-merged, caption-as-boundary splits |
| 5 | Embedded caption inside figure | Caption is not an asset | Ignored by clustering | N/A |
| 6 | Figure + table on same page | Split by type (figure_asset vs table_asset) | Cluster separately | Type-independent position fallback |
| 7 | Multi-column figures same y | n_legends >= 2 | Caption-as-boundary split | Single_asset fallback |
| 8 | Orphan single-panel figure | Cluster of size 1 | Standalone candidate | Existing score_figure_match |
| 9 | Text-separated figures | Text separator exists | Don't merge across | Distance threshold without separator |
| 10 | Very large multi-panel (>10 panels) | Many assets, single legend | Cluster merges all; sequential expansion collects stragglers | Existing `_expand_matched_assets_locally` |
---
## 4. Files to change
### `paperforge/worker/ocr_figures.py`
| Function | Lines | Change |
|----------|-------|--------|
| `_build_candidate_figure_groups_from_assets` | 487-603 | Replace entire body. Remove single_asset, same_row, page_assets, region_growth. Add clustering. |
| `_grow_region_from_seed` | 361-389 | DELETE (replaced by clustering) |
| `_validate_grown_region` | 392-413 | DELETE (validation moves into clustering) |
| `_asset_gap_left` | (new) | DELETE (added in previous fix, no longer needed) |
| `_asset_gap_above` | (new) | DELETE (no longer needed) |
| `_score_legend_to_group` | 608-662 | MODIFY: add `"distance_cluster"` case returning score=0.85, `decision="matched"` when n_legends==1 |
| `_expand_matched_assets_locally` | 665-? | No change (cross-page fallback stays) |
| NEW `_cluster_page_assets` | (new) | ADD: main clustering function |
| NEW `_has_text_separator` | (new) | ADD: text separator detection |
### `paperforge/worker/ocr_figures.py` — detailed function changes
**`_build_candidate_figure_groups_from_assets()`** — replacement:
```python
def _build_candidate_figure_groups_from_assets(
assets: list[dict], blocks: list[dict],
legends: list[dict], page_width: float = 1200
) -> list[dict]:
media = _filter_figure_assets(assets)
groups: list[dict] = []
next_id = 1
by_page: dict[int, list[dict]] = {}
for block in media:
by_page.setdefault(int(block.get("page", 0) or 0), []).append(block)
for page, page_media in by_page.items():
if len(page_media) == 0:
continue
# Count legends on this page
page_legends = [l for l in legends if l.get("page") == page]
# Cluster assets by distance
clusters = _cluster_page_assets(page_media, blocks, page_width)
for cluster in clusters:
entry = _candidate_group_entry(
f"group_{next_id:04d}", page, cluster,
"distance_cluster",
["same_page", "distance_clustered"],
)
groups.append(entry)
next_id += 1
# Single assets not in any cluster → individual groups
clustered_ids = set()
for c in clusters:
for bid in [b.get("block_id") for b in c]:
clustered_ids.add(str(bid))
for block in page_media:
if str(block.get("block_id", "")) not in clustered_ids:
groups.append(_candidate_group_entry(
f"group_{next_id:04d}", page, [block],
"single_asset", ["same_page", "single_asset"]
))
next_id += 1
return groups
```
**`_cluster_page_assets()`** — new function:
```python
def _cluster_page_assets(
page_assets: list[dict],
all_blocks: list[dict],
page_width: float,
) -> list[list[dict]]:
"""Cluster assets by spatial proximity with text-separator awareness."""
if len(page_assets) <= 1:
return [list(page_assets)]
# Get page height from first asset's bbox
page_height = 1600.0 # default
for b in all_blocks:
ph = b.get("page_height")
if ph:
page_height = float(ph)
break
h_threshold = max(page_width * 0.12, 40.0)
v_threshold = max(page_height * 0.08, 40.0)
# Union-find
parent = list(range(len(page_assets)))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px != py:
parent[py] = px
for i in range(len(page_assets)):
for j in range(i + 1, len(page_assets)):
a, b = page_assets[i], page_assets[j]
ab = a.get("bbox", [0, 0, 0, 0])
bb = b.get("bbox", [0, 0, 0, 0])
h_gap = max(0.0, bb[0] - ab[2], ab[0] - bb[2])
v_gap = max(0.0, bb[1] - ab[3], ab[1] - bb[3])
if h_gap > h_threshold:
# Check y-overlap for irregular layouts
a_y1, a_y2 = ab[1], ab[3]
b_y1, b_y2 = bb[1], bb[3]
y_overlap = max(0, min(a_y2, b_y2) - max(a_y1, b_y1))
shorter_h = min(a_y2 - a_y1, b_y2 - b_y1)
if shorter_h > 0 and y_overlap / shorter_h < 0.5:
continue # no horizontal overlap and not vertically aligned → separate clusters
if v_gap > v_threshold:
continue # too far apart vertically → separate clusters
# Text separator check
if _has_text_separator(a, b, all_blocks):
continue
union(i, j)
# Build clusters
clusters: dict[int, list[dict]] = {}
for i, block in enumerate(page_assets):
root = find(i)
if root not in clusters:
clusters[root] = []
clusters[root].append(block)
return list(clusters.values())
```
**`_has_text_separator()`** — new function:
```python
def _has_text_separator(a: dict, b: dict, all_blocks: list[dict]) -> bool:
"""True if body text or heading exists in the gap between assets a and b."""
ab = a.get("bbox", [0, 0, 0, 0])
bb = b.get("bbox", [0, 0, 0, 0])
gap_top = min(ab[3], bb[3])
gap_bot = max(ab[1], bb[1])
if gap_bot <= gap_top:
return False # assets overlap vertically, no gap to check
gap_left = min(ab[0], bb[0])
gap_right = max(ab[2], bb[2])
for block in all_blocks:
role = block.get("role", "")
if role not in ("body_paragraph", "section_heading", "subsection_heading"):
continue
txt = str(block.get("text", "") or "").strip()
if not txt:
continue
cb = block.get("bbox", [0, 0, 0, 0])
# Block must be inside the gap rectangle
if (cb[0] >= gap_left and cb[2] <= gap_right
and cb[1] >= gap_top and cb[3] <= gap_bot):
return True
return False
```
**`_score_legend_to_group()`** — modification:
```python
# Add before the `if gt == "page_assets":` block:
if gt == "distance_cluster":
return {
"score": 0.85,
"decision": "matched",
"evidence": ["same_page", "distance_clustered"],
}
```
This replaces the 0.55 flat score for `page_assets` with a 0.85 guaranteed match for distance-clustered groups.
---
## 5. Acceptance criteria
1. **SAN9AYVR Figure 23**: All sub-panels (a-h) merged into one figure, 0 unmatched assets on page 50.
2. **2GN9LMCW Figure 4**: All 6 assets merged (was previously partially merged).
3. **DWQQK2YB Figure 2**: Stays correctly on page 38 with 19 assets (existing fix, must not regress).
4. **3FDT9652**: No regression on multi-column figures (separate figures in different columns with their own captions must not merge).
5. **2GN9LMCW Figures 1-3**: Single-panel figures remain as independent figures (clusters of size 1).
6. **No page-swallow**: A page with 10+ assets across 3 separate figures must not merge them all (text separator + caption-as-boundary prevents this).
---
## 6. Risks and mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Over-merge (2 separate figs → 1 cluster) | Medium | Medium | Caption-as-boundary split when n_legends >= 2 |
| Under-merge (single fig → 2+ clusters) | Low | Low | `_expand_matched_assets_locally` collects orphans |
| Text separator false positive (body text between sub-panels of same fig) | Low | Medium | Text inside display_zone is usually caption/figure text, not body_paragraph. Real body text between sub-panels is rare in published papers. |
| Distance threshold too tight for large figures | Low | Low | y-overlap signal merges irregular layouts. Cross-page fallback catches stragglers. |
| Performance (pairwise O(n²)) | Low | Low | Pages with > 50 assets are rare (< 1% of corpus). Union-find is O(n log n). |
---
## 7. Files summary
| File | Action |
|------|--------|
| `paperforge/worker/ocr_figures.py` | Replace `_build_candidate_figure_groups_from_assets` body; add `_cluster_page_assets` + `_has_text_separator`; delete `_grow_region_from_seed` + `_validate_grown_region`; modify `_score_legend_to_group` |
| `tests/test_ocr_figures.py` | Add tests for distance clustering, text separator, multi-legend split, irregular layout |
| `tests/test_ocr_real_paper_regressions.py` | Add SAN9AYVR regression test for Figure 23 merge |
| `PROJECT-MANAGEMENT.md` | Updated with analysis (already done) |
---
## 8. Rejected alternatives
| Alternative | Why rejected |
|-------------|-------------|
| Fix region-growth scoring (new bonus/threshold) | Doesn't solve the greedy-first-seed-wins problem |
| Add more growth directions (8-direction) | Still local, still greedy, still no backtracking |
| Machine learning for figure grouping | Over-engineering. Distance clustering is deterministic, auditable, and sufficient. |
| Per-page manual layout templates | Not generalizable to arbitrary journal formats |

View file

@ -1418,6 +1418,25 @@ def _block_in_any_reference_zone(
return any(global_index in zone.block_indices for zone in zones)
def repair_reference_entry_from_pdf_text(block_run: list[dict], page_text: str) -> str:
seed = " ".join(
str(block.get("text") or "").strip()
for block in block_run
if str(block.get("text") or "").strip()
)
seed = re.sub(r"\s+", " ", seed).strip()
if not seed:
return ""
parts = [part for part in seed.split() if len(part) > 2]
if not parts:
return seed
first = parts[0]
idx = page_text.find(first)
if idx < 0:
return seed
return page_text[idx : idx + 300].strip()
def _detect_forward_body_end(
blocks: list[dict],
page_layouts: dict[int, PageLayoutProfile] | None = None,
@ -2391,6 +2410,68 @@ def analyze_document_structure(blocks: list[dict]) -> DocumentStructure:
return ds
def compute_layout_facts(blocks: list[dict]) -> list[dict]:
facts: list[dict] = []
band_seq = 0
last_key: tuple[int, str, int] | None = None
ref_roles = {"reference_heading", "reference_item"}
display_roles = {"figure_asset", "media_asset", "table_html", "figure_caption", "table_caption"}
tail_zones = {"tail_nonref_hold_zone", "post_reference_backmatter_zone"}
side_roles = {"structured_insert", "structured_insert_candidate", "non_body_insert"}
fm_zones = {"frontmatter_side_zone", "frontmatter_main_zone"}
def _sort_key(block: dict) -> tuple[int, float, float]:
bbox = block.get("bbox") or [0, 0, 0, 0]
return (
int(block.get("page", 0) or 0),
float(bbox[0]) if len(bbox) >= 1 else 0.0,
float(bbox[1]) if len(bbox) >= 2 else 0.0,
)
for block in sorted(blocks, key=_sort_key):
page = int(block.get("page", 0) or 0)
bbox = block.get("bbox") or [0, 0, 0, 0]
role = str(block.get("role") or "")
zone = str(block.get("zone") or "")
text = str(block.get("text") or block.get("block_content") or "").strip()
if zone == "reference_zone" or role in ref_roles:
layout_region = "reference_candidate"
elif zone == "display_zone" or role in display_roles:
layout_region = "display_zone"
elif zone in tail_zones or role.startswith("backmatter"):
layout_region = "tail_candidate"
elif zone in fm_zones and role in side_roles:
layout_region = "side_insert"
else:
layout_region = "body_flow"
col_key = 0 if len(bbox) < 4 else (0 if float(bbox[0]) < 600 else 1)
key = (page, layout_region, col_key)
if key != last_key:
band_seq += 1
last_key = key
facts.append(
{
"block_id": str(block.get("block_id") or ""),
"reading_band_id": f"band_{band_seq:03d}",
"display_cluster_candidate_id": f"disp_{page}_{col_key}" if layout_region == "display_zone" else "",
"layout_region": layout_region,
"boundary_before": (
"weak"
if role == "reference_item"
else (
"hard"
if layout_region in {"reference_candidate", "display_zone"}
and role not in {"figure_asset", "media_asset", "table_html"}
else "none"
)
),
"boundary_after": "none",
"bridge_eligible": role == "unknown_structural" and not text and layout_region == "display_zone",
}
)
return facts
def _apply_zone_labels(blocks: list[dict], region_bus: dict[str, dict] | None) -> None:
if not region_bus:
return
@ -2497,6 +2578,56 @@ def _apply_content_zone_fallback(blocks: list[dict], region_bus: dict[str, dict]
block["zone"] = "body_zone"
def _recover_empty_text_from_spans(blocks: list[dict]) -> None:
"""Recover text for blocks where OCR detected spans but assembled text is empty.
When a block has ``raw_label == "text"``, non-empty ``span_metadata``, and
empty block-level text, the text can be reconstructed from span content.
These blocks are typically body paragraphs whose OCR assembly step dropped
the text but whose individual spans retained it.
"""
recovered_count = 0
for block in blocks:
if str(block.get("text") or block.get("block_content") or "").strip():
continue
spans = block.get("span_metadata")
if not spans or not isinstance(spans, list) or len(spans) == 0:
continue
raw_label = str(block.get("raw_label") or "")
if raw_label not in {"text", "paragraph_title", "header"}:
continue
parts: list[str] = []
for span in spans:
st = str(span.get("text") or "").strip()
if st and not st.isdigit() and len(st) > 1:
parts.append(st)
if parts:
recovered = " ".join(parts)
if not _looks_like_author_list(recovered) and not _looks_like_affiliation(recovered):
block["text"] = recovered
block["block_content"] = recovered
block["_text_source"] = "span_recovery"
block["role"] = "body_paragraph"
block["seed_role"] = "body_paragraph"
recovered_count += 1
record_decision(
block,
stage="empty_text_span_recovery",
old_role=block.get("role", "unknown_structural"),
new_role=block["role"],
reason=f"recovered {len(recovered)} chars from {len(parts)} spans",
)
# If span text is also empty, mark as needing PDF fallback so
# downstream callers (audit refresh) can attempt backfill via PDF
# and then re-normalize with seed_role set.
if not parts:
block["_needs_pdf_fallback"] = True
if not block.get("text") and str(block.get("raw_label") or "") == "text":
# Give a temporary seed_role so re-normalize treats it as body
block["seed_role"] = block.get("seed_role") or "body_paragraph"
return recovered_count
def _sanitize_reference_zone_boundary(blocks: list[dict]) -> None:
"""Defensive pass: ensure only reference_heading / reference_item live inside reference_zone.
@ -4544,7 +4675,13 @@ def _should_keep_formal_caption_seed(block: dict) -> bool:
return False
if marker in {"figure_number", "table_number"}:
return True
return zone == "display_zone" and style in {"legend_like", "support_like"} and text.lower().startswith(("fig", "figure", "table"))
# Ponytail: accept as caption when text identifies itself as figure/table
# label OR has substantial descriptive text typical of captions (>= 80 chars)
# AND is in a display-paired zone or legend-like style
text_lower = text.lower()
looks_like_caption = text_lower.startswith(("fig", "figure", "table")) or len(text) >= 80
in_display_context = zone in {"display_zone", "body_zone"} or style in {"legend_like", "support_like"}
return looks_like_caption and in_display_context
def _demote_early_frontmatter_body_leaks(blocks: list[dict]) -> None:
@ -4581,6 +4718,13 @@ def _demote_early_frontmatter_body_leaks(blocks: list[dict]) -> None:
block["role"] = "frontmatter_noise"
block["render_default"] = False
continue
# Journal badge/marker text above the real title — not body content
if lower in ("highlighted paper", "research highlight", "highlight", "research article",
"review article", "original article", "editorial", "letter", "perspective",
"communication", "technical note", "case report"):
block["role"] = "frontmatter_noise"
block["render_default"] = False
continue
def _restore_numbered_body_from_tail_hold(blocks: list[dict]) -> None:
@ -4615,16 +4759,14 @@ def _restore_numbered_body_from_tail_hold(blocks: list[dict]) -> None:
def normalize_document_structure(
blocks: list[dict],
source_frontmatter_anchors: dict | None = None,
pdf_path: str | None = None,
) -> tuple[DocumentStructure, list[dict]]:
"""Analyze document structure and normalize roles.
Returns (document_structure, normalized_blocks).
Normalization includes:
- backmatter form classification
- backmatter role normalization after boundary
- tail body candidate promotion
- tail spread ownership assignment
"""
Returns (document_structure, normalized_blocks).
"""
if pdf_path:
from paperforge.worker.ocr_pdf_spans import backfill_missing_text_from_pdf
backfill_missing_text_from_pdf(blocks, pdf_path)
page_layouts = _build_page_layout_profiles(blocks)
body_family_anchor = discover_body_family_anchor(blocks)
reference_family_anchor = discover_reference_family_anchor(blocks)
@ -4647,6 +4789,7 @@ def normalize_document_structure(
)
_exclude_frontmatter_side_from_body_flow(blocks)
_exclude_tail_nonref_from_body_flow(blocks)
_recover_empty_text_from_spans(blocks)
from paperforge.worker.ocr_roles import resolve_final_role
@ -4670,6 +4813,23 @@ def normalize_document_structure(
if resolved.evidence:
block.setdefault("evidence", []).extend(resolved.evidence)
try:
layout_facts = compute_layout_facts(blocks)
fact_by_id = {row["block_id"]: row for row in layout_facts if row.get("block_id")}
for block in blocks:
block_id = str(block.get("block_id") or "")
fact = fact_by_id.get(block_id)
if not fact:
continue
block["reading_band_id"] = fact["reading_band_id"]
block["display_cluster_candidate_id"] = fact["display_cluster_candidate_id"]
block["layout_region"] = fact["layout_region"]
block["boundary_before"] = fact["boundary_before"]
block["boundary_after"] = fact["boundary_after"]
block["bridge_eligible"] = fact["bridge_eligible"]
except Exception:
pass
# Backmatter heading candidate promotion: confirm candidates that are
# followed by body-like text on the same page. This runs after role
# resolution (which may have reclassified the candidate via editorial
@ -5151,6 +5311,10 @@ def normalize_document_structure(
"frontmatter_support",
"structured_insert",
"non_body_insert",
"subsection_heading",
"reference_item",
"table_caption",
"table_html",
}:
block["role_source"] = "structural_gate_fallback"
continue
@ -5173,3 +5337,34 @@ def normalize_document_structure(
block["role"] = seed_role
return doc_structure, blocks
def score_reference_corridor_membership(block: dict) -> dict:
from paperforge.worker.ocr_reference_signals import score_reference_entry
text = str(block.get("text") or block.get("block_content") or "").strip()
role = str(block.get("role") or "")
layout_region = str(block.get("layout_region") or "")
style = score_reference_entry(text)
ref_membership_score = 0.0
if role in {"reference_heading", "reference_item"}:
ref_membership_score += 0.6
if layout_region == "reference_candidate":
ref_membership_score += 0.2
ref_membership_score += float(style.get("confidence") or 0.0) * 0.3
intrusion = 0.0
lower = text.lower()
if role.startswith("backmatter"):
intrusion += 0.4
if any(token in lower for token in ("acknowledgements", "funding", "conflict of interest", "data availability")):
intrusion += 0.8
if layout_region in {"body_flow", "display_zone", "side_insert"} and role not in {"reference_heading", "reference_item"}:
intrusion += 0.3
return {
"ref_membership_score": ref_membership_score,
"non_ref_intrusion_score": intrusion,
"reference_style_family": style["family"],
"reference_style_confidence": style["confidence"],
"accept_reference_membership": ref_membership_score >= 0.55 and intrusion < 0.5,
}

View file

@ -64,33 +64,24 @@ _INLINE_FIGURE_MENTION_VERBS = (
)
def _looks_like_inline_figure_mention(text: str) -> bool:
t = " ".join(text.strip().split())
lower = t.lower()
# Text starting with "Figure N." / "Fig. N." is a self-identifying
# caption — never flag as body mention regardless of verb content.
if re.match(r"^(?:Figure|Fig\.?)\s+\d+\.\b", t, re.IGNORECASE):
return False
if re.match(r"^(?:Figure|Fig\.?)\s+\d+\s+(?:" + "|".join(_INLINE_FIGURE_MENTION_VERBS) + r")\b", t, re.IGNORECASE):
return True
if not re.search(r"\bfi(?:g(?:ure)?\.?\s*\d+)", lower):
return False
# Explicitly NOT inline: Frontiers format FIGURE N | ...
if re.match(r"^figure\s+\d+[a-z]?\s*\|", t, re.I):
return False
# "as shown in Figure X" / "shown in Figure X" / "see Figure X"
if re.search(r"\b(as shown in|shown in|see |according to|consistent with)\s+(fig(?:ure)?\.?\s*\d+)", lower):
return True
# Long sentence with a prose verb
words = t.split()
if len(words) >= 10 and any(re.search(rf"\b{v}\b", lower) for v in _INLINE_FIGURE_MENTION_VERBS):
return True
def _looks_like_inline_figure_mention(text: str, block: dict | None = None) -> bool:
"""
Ponytail: position and style are the reliable signals for distinguishing
figure captions from body mentions. Text-content heuristics (verb lists,
word-count thresholds) introduce errors for long captions and non-English
prose. Default to False when the block is already classified as a figure
caption in a display zone.
"""
if block is not None:
role = str(block.get("role") or "")
zone = str(block.get("zone") or "")
style = str(block.get("style_family") or "")
if role == "figure_caption":
return False
if role == "figure_caption_candidate" and zone != "body_zone":
return False
if zone == "display_zone" and style == "legend_like":
return False
return False
@ -179,7 +170,7 @@ def _is_body_mention(block: dict) -> bool:
if raw_role == "figure_caption_candidate":
text = block.get("text", "")
if text and _FIGURE_NUMBER_PATTERN.search(text):
if _looks_like_inline_figure_mention(text):
if _looks_like_inline_figure_mention(text, block):
return True
return False
if text and _looks_like_figure_narrative_prose(text):
@ -367,6 +358,18 @@ def _asset_gap_below(a: dict, b: dict) -> float:
return max(0.0, bb[1] - ab[3])
def _asset_gap_left(a: dict, b: dict) -> float:
ab = a.get("bbox") or [0, 0, 0, 0]
bb = b.get("bbox") or [0, 0, 0, 0]
return max(0.0, ab[0] - bb[2])
def _asset_gap_above(a: dict, b: dict) -> float:
ab = a.get("bbox") or [0, 0, 0, 0]
bb = b.get("bbox") or [0, 0, 0, 0]
return max(0.0, ab[1] - bb[3])
def _grow_region_from_seed(seed: dict, others: list[dict], page_width: float) -> dict:
group = [seed]
growth_steps: list[dict] = []
@ -379,10 +382,14 @@ def _grow_region_from_seed(seed: dict, others: list[dict], page_width: float) ->
for candidate in remaining:
reason = None
cb = candidate.get("bbox") or [0, 0, 0, 0]
if cb[0] >= group_bbox[2] and _asset_gap_right({"bbox": group_bbox}, candidate) <= page_width * 0.03:
if cb[0] >= group_bbox[2] and _asset_gap_right({"bbox": group_bbox}, candidate) <= max(page_width * 0.08, 40):
reason = "adjacent_right"
elif cb[1] >= group_bbox[3] and _asset_gap_below({"bbox": group_bbox}, candidate) <= page_width * 0.03:
elif cb[1] >= group_bbox[3] and _asset_gap_below({"bbox": group_bbox}, candidate) <= max(page_width * 0.08, 40):
reason = "adjacent_below"
elif cb[2] <= group_bbox[0] and _asset_gap_left({"bbox": group_bbox}, candidate) <= max(page_width * 0.08, 40):
reason = "adjacent_left"
elif cb[3] <= group_bbox[1] and _asset_gap_above({"bbox": group_bbox}, candidate) <= max(page_width * 0.08, 40):
reason = "adjacent_above"
if reason:
group.append(candidate)
growth_steps.append({"added_block_id": candidate.get("block_id", ""), "reason": reason})
@ -429,7 +436,10 @@ def _media_clusters(blocks: list[dict], page_width: float = 1200) -> list[list[d
if not b.get("_non_body_media")
and (
b.get("role") == "figure_asset"
or (b.get("role") == "media_asset" and b.get("raw_label", "") in {"image", "chart", "figure"})
or (b.get("role") == "media_asset" and (
b.get("raw_label", "") in {"image", "chart", "figure"}
or (b.get("raw_label", "") == "table" and "<img" in str(b.get("text") or "").lower())
))
)
]
media.sort(key=lambda b: (b.get("page", 0), b.get("bbox", [0, 0, 0, 0])[1], b.get("bbox", [0, 0, 0, 0])[0]))
@ -499,7 +509,11 @@ def _build_candidate_figure_groups_from_assets(assets: list[dict], page_width: f
b.get("role") == "figure_asset"
or (
b.get("role") == "media_asset"
and (b.get("raw_label", "") in {"image", "chart", "figure"} or not str(b.get("raw_label", "")).strip())
and (
b.get("raw_label", "") in {"image", "chart", "figure"}
or (b.get("raw_label", "") == "table" and "<img" in str(b.get("text") or "").lower())
or not str(b.get("raw_label") or "").strip()
)
)
)
]
@ -639,12 +653,14 @@ def _score_legend_to_group(
)
num_assets = len(group.get("media_blocks", []))
if num_assets > 1 and match_score.get("score", 0) > 0:
coherence_bonus = 0.05
coherence_bonus = 0.15
match_score = dict(match_score)
match_score["score"] = min(1.0, match_score["score"] + coherence_bonus)
match_score.setdefault("evidence", []).append("multi_asset_coherence_bonus")
if match_score["score"] >= 0.6 and match_score.get("decision") == "ambiguous":
if match_score["score"] >= 0.5 and match_score.get("decision") in ("ambiguous", "candidate"):
match_score["decision"] = "matched"
if match_score.get("decision") == "rejected":
match_score["decision"] = "candidate"
return match_score
@ -875,7 +891,7 @@ def _allow_previous_page_sequential_match(cap: dict, asset: dict) -> bool:
page_width = float(cap.get("page_width") or asset.get("page_width") or 1200)
page_height = float(cap.get("page_height") or asset.get("page_height") or 1600)
strong_numbered_caption = _extract_figure_number(str(cap.get("text", ""))) is not None
post_reference_layout = str(cap.get("zone") or "") == "post_reference_backmatter_zone"
post_reference_layout = str(cap.get("zone") or "") in {"post_reference_backmatter_zone", "display_zone"}
starts_page = cap_top <= 220
prior_asset_near_bottom = asset_bottom >= page_height * 0.7
wide_caption = cap_width >= page_width * 0.6
@ -944,6 +960,18 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
unresolved_clusters: list[dict] = []
ambiguous_figures: list[dict] = []
def _collect_bridge_blocks(page: int) -> list[dict]:
bridges: list[dict] = []
for block in structured_blocks:
if int(block.get("page", 0) or 0) != page:
continue
if not block.get("bridge_eligible"):
continue
if str(block.get("layout_region") or "") != "display_zone":
continue
bridges.append(block)
return bridges
for block in structured_blocks:
if block.get("page_width"):
page_width = float(block["page_width"])
@ -957,15 +985,14 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
continue
is_validation_first_candidate = _is_validation_first_legend_candidate(block)
if role in ("figure_caption", "figure_caption_candidate") or is_validation_first_candidate:
if _is_body_mention(block):
unmatched_legends.append(block)
continue
if (
role == "figure_caption_candidate"
and str(block.get("zone") or "") != "display_zone"
and str(block.get("style_family") or "") != "legend_like"
and _looks_like_figure_narrative_prose(block.get("text", ""))
and _extract_figure_number(block.get("text", "")) is None
if role == "figure_caption_candidate" and (
str(block.get("zone") or "") == "body_zone"
or (
str(block.get("zone") or "") != "display_zone"
and str(block.get("style_family") or "") != "legend_like"
and _looks_like_figure_narrative_prose(block.get("text", ""))
and _extract_figure_number(block.get("text", "")) is None
)
):
continue
if not _is_formal_legend(block.get("text", ""), block, page_width):
@ -973,7 +1000,7 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
block,
nearby_media=False,
caption_style_match=False,
body_prose_likelihood=_looks_like_inline_figure_mention(block.get("text", "")),
body_prose_likelihood=_looks_like_inline_figure_mention(block.get("text", ""), block),
)
rejected_legends.append(block)
else:
@ -984,6 +1011,8 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
raw_label = str(block.get("raw_label", "")).strip()
if raw_label in {"image", "chart", "figure_title", "figure"} or not raw_label:
assets.append(block)
elif raw_label == "table" and "<img" in str(block.get("text") or "").lower():
assets.append(block)
numbered_legends = [leg for leg in legends if _extract_figure_number(leg.get("text", "")) is not None]
unnumbered_legends = [leg for leg in legends if _extract_figure_number(leg.get("text", "")) is None]
@ -1009,7 +1038,10 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
if current_has_asset and not existing_has_asset:
_dedup_map[key] = legend
elif current_has_asset and existing_has_asset or not current_has_asset and not existing_has_asset:
pass
existing_role = str(existing.get("role") or "")
current_role = str(legend.get("role") or "")
if current_role == "figure_caption" and existing_role != "figure_caption":
_dedup_map[key] = legend
seen_fig_keys: set[tuple[str, int]] = set()
deduped_legends: list[dict] = []
@ -1038,11 +1070,13 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
# Gate: suppress page_assets groups on pages with competing captions
# so one big group doesn't swallow assets meant for multiple figures.
# Only count legends with a real figure number — sub-panel labels
# ("Tensile strength", "RND") are not captions and should not gate.
_page_captions_for_gate: dict[int, set[str]] = {}
for block in structured_blocks:
role = block.get("role", "")
if role in ("figure_caption", "figure_caption_candidate") and block.get("block_id"):
_page_captions_for_gate.setdefault(block.get("page", 0), set()).add(block["block_id"])
for legend in ordered_legends:
fid = legend.get("block_id")
if fid and _extract_figure_number(str(legend.get("text") or "")) is not None:
_page_captions_for_gate.setdefault(legend.get("page", 0), set()).add(str(fid))
_competing_caption_pages = {p for p, ids in _page_captions_for_gate.items() if len(ids) > 1}
candidate_groups = [
g
@ -1054,13 +1088,22 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
page_caption_index = _formal_figure_caption_blocks(structured_blocks)
for legend in ordered_legends:
legend_page = legend.get("page", 0)
legend_text = legend.get("text", "")
legend_text = str(legend.get("text") or "")
ns = _extract_figure_namespace(legend_text)
fig_num = _extract_figure_number(legend_text)
# Text may be empty (stored as [] in raw block) but marker_signature
# still carries the figure number.
if fig_num is None:
ms = legend.get("marker_signature") or {}
if ms.get("type") == "figure_number":
try:
fig_num = int(float(ms.get("number", 0) or 0))
except (ValueError, TypeError):
pass
is_validation_first_candidate = _is_validation_first_legend_candidate(legend)
is_weak_truncated = _is_insufficient_legend_evidence(legend)
body_prose_likelihood = _looks_like_inline_figure_mention(legend_text)
body_prose_likelihood = _looks_like_inline_figure_mention(legend_text, legend)
caption_score = score_figure_caption(
legend,
@ -1111,26 +1154,40 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
matched_assets = []
elif len(close) > 1:
close_sides = {_asset_vertical_side(legend, g) for _, g, _ in close}
if "above" in close_sides and "below" in close_sides:
ambiguous_figures.append(
{
"legend_block_id": legend.get("block_id", ""),
"page": legend_page,
"caption_score": caption_score,
"candidates": [
if "above" in close_sides and "below" in close_sides and fig_num is not None:
# If one candidate is the page_assets group (all assets),
# don't tie-break — it's the canonical match.
page_assets_candidate = next((item for item in close if item[1].get("group_type") == "page_assets"), None)
if page_assets_candidate is not None:
close = [page_assets_candidate]
else:
# Caption sandwiched between two asset groups:
# prefer above (standard: image above, caption below).
above_only = [item for item in close if _asset_vertical_side(legend, item[1]) == "above"]
if above_only:
close = above_only
else:
ambiguous_figures.append(
{
"asset_block_id": g.get("media_blocks", [{}])[0].get("block_id", ""),
"group_type": g.get("group_type", ""),
"match_score": s,
"legend_block_id": legend.get("block_id", ""),
"page": legend_page,
"text": legend_text,
"figure_number": fig_num,
"caption_score": caption_score,
"candidates": [
{
"asset_block_id": g.get("media_blocks", [{}])[0].get("block_id", ""),
"group_type": g.get("group_type", ""),
"match_score": s,
}
for _, g, s in close
],
"hold_reason": "close_asset_tie",
}
for _, g, s in close
],
"hold_reason": "close_asset_tie",
}
)
ambiguous = True
matched_assets = []
close = []
)
ambiguous = True
matched_assets = []
close = []
if not close:
pass
else:
@ -1177,11 +1234,15 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
if len(matched_assets) > 1:
region_match["cluster_bbox"] = best_group.get("cluster_bbox", [0, 0, 0, 0])
else:
if fig_num is None and str(legend.get("role") or "") in ("figure_caption", "figure_caption_candidate") and len(legend_text) < 80:
unmatched_legends.append(legend)
continue
ambiguous_figures.append(
{
"legend_block_id": legend.get("block_id", ""),
"page": legend_page,
"caption_score": caption_score,
"figure_number": fig_num,
"candidates": [
{
"asset_block_id": g.get("media_blocks", [{}])[0].get("block_id", ""),
@ -1190,10 +1251,13 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
}
for _, g, s in close
],
"hold_reason": "close_unconfirmed_match",
}
)
ambiguous = True
matched_assets = []
ambiguous = True
matched_assets = []
else:
best_gi, best_group, best_score = candidates[0]
if best_score["decision"] == "matched":
@ -1216,11 +1280,15 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
if len(matched_assets) > 1:
region_match["cluster_bbox"] = best_group.get("cluster_bbox", [0, 0, 0, 0])
else:
if fig_num is None and str(legend.get("role") or "") in ("figure_caption", "figure_caption_candidate"):
unmatched_legends.append(legend)
continue
ambiguous_figures.append(
{
"legend_block_id": legend.get("block_id", ""),
"page": legend_page,
"caption_score": caption_score,
"figure_number": fig_num,
"candidates": [
{
"asset_block_id": best_group.get("media_blocks", [{}])[0].get("block_id", ""),
@ -1228,9 +1296,11 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
"match_score": best_score,
}
],
"hold_reason": "single_unconfirmed_match",
}
)
ambiguous = True
ambiguous = True
is_legend_only = len(matched_assets) == 0
@ -1238,7 +1308,7 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
unmatched_legends.append(legend)
continue
if is_weak_truncated and is_validation_first_candidate:
if is_weak_truncated and is_validation_first_candidate and is_legend_only:
held_figures.append(
{
"figure_id": f"held_figure_{len(held_figures) + 1:03d}",
@ -1256,25 +1326,49 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
unmatched_legends.append(legend)
continue
# When multiple truncated legends ("Fig. 4" only) share a page,
# they are sub-panel labels on a multi-figure gallery — skip them
# and let the shared caption claim all assets.
if is_weak_truncated:
ambiguous_figures.append(
{
"legend_block_id": legend.get("block_id", ""),
"page": legend_page,
"text": legend_text,
"figure_number": fig_num,
"caption_score": caption_score,
"candidates": [],
"hold_reason": "ambiguous_truncated_legend",
"zone": legend.get("zone"),
"style_family": legend.get("style_family"),
"marker_signature": legend.get("marker_signature") or {},
}
page_truncated_count = sum(
1 for leg in ordered_legends
if leg.get("page", 0) == legend_page and _is_insufficient_legend_evidence(leg)
)
unmatched_legends.append(legend)
continue
if page_truncated_count >= 2:
unmatched_legends.append(legend)
continue
# Single truncated legend on page: match normally with candidates
if is_legend_only:
weak_candidates = [
{
"asset_block_id": g.get("media_blocks", [{}])[0].get("block_id", ""),
"group_type": g.get("group_type", ""),
"match_score": s,
}
for _, g, s in (candidates or [])
]
ambiguous_figures.append(
{
"legend_block_id": legend.get("block_id", ""),
"page": legend_page,
"text": legend_text,
"figure_number": fig_num,
"caption_score": caption_score,
"candidates": weak_candidates,
"hold_reason": "ambiguous_truncated_legend",
"zone": legend.get("zone"),
"style_family": legend.get("style_family"),
"marker_signature": legend.get("marker_signature") or {},
}
)
unmatched_legends.append(legend)
continue
# Single truncated legend with matched assets — proceed to confirmation
if is_legend_only:
if fig_num is None and str(legend.get("role") or "") in ("figure_caption_candidate", "figure_caption"):
unmatched_legends.append(legend)
continue
weak_entry = {
"legend_block_id": legend.get("block_id", ""),
"page": legend_page,
@ -1323,6 +1417,9 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
"flags": [],
"caption_score": caption_score,
}
local_bridges = _collect_bridge_blocks(int(legend_page or 0))
entry["asset_block_ids"] = [str(a.get("block_id", "")) for a in matched_assets if a.get("block_id")]
entry["bridge_block_ids"] = [str(b.get("block_id", "")) for b in local_bridges if b.get("block_id")]
if len(matched_assets) > 1:
entry["cluster_bbox"] = _cluster_bbox([a.get("bbox", [0, 0, 0, 0]) for a in matched_assets])
matched_figures.append(entry)
@ -1433,6 +1530,103 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
matched_figures.extend(sidecar_promoted)
used_asset_page_ids.update(sidecar_consumed_ids)
# Preproof legend-bundling: when a page packs 3+ figure captions
# with zero same-page assets, match them 1:1 by page order to
# subsequent pages that each hold unclaimed assets.
if unmatched_legends and unmatched_assets:
page_captions: dict[int, list[dict]] = {}
for leg in unmatched_legends:
cp = int(leg.get("page", 0) or 0)
if _extract_figure_number(str(leg.get("text", ""))) is not None:
page_captions.setdefault(cp, []).append(leg)
for cp, caps in sorted(page_captions.items()):
if len(caps) < 3:
continue
page_has_assets = any(
a.get("page", 0) == cp for a in unmatched_assets
)
if page_has_assets:
continue
caps_sorted = sorted(caps, key=lambda b: (b.get("bbox") or [0, 0, 0, 0])[1])
# Collect subsequent pages with unclaimed assets and no captions
asset_pages: dict[int, list[dict]] = {}
for ast in unmatched_assets:
ap = int(ast.get("page", 0) or 0)
bid = ast.get("block_id", "")
if ap <= cp:
continue
if bid and (ap, bid) in used_asset_page_ids:
continue
asset_pages.setdefault(ap, []).append(ast)
page_order = sorted(asset_pages.keys())
# Validate: no body/table blocks between legend page and first asset page,
# and each asset page is free of competing body/table text.
_NON_PURE_ROLES = {"body_paragraph", "section_heading", "subsection_heading",
"table_caption", "table_asset", "table_html",
"backmatter_heading", "backmatter_body", "reference_item"}
intervening_pages = set(range(cp + 1, page_order[0])) if page_order else set()
intervening_body = any(
b.get("page", 0) in intervening_pages and b.get("role", "") in _NON_PURE_ROLES
for b in structured_blocks
)
if intervening_body:
continue
valid_pages = []
for ap in page_order:
page_has_body = any(
b.get("page", 0) == ap and b.get("role", "") in _NON_PURE_ROLES
for b in structured_blocks
)
if not page_has_body:
valid_pages.append(ap)
if len(valid_pages) < len(caps_sorted):
caps_sorted = caps_sorted[:len(valid_pages)]
if not valid_pages:
continue
# Match captions to validated asset pages in order
for idx, cap in enumerate(caps_sorted):
if idx >= len(valid_pages):
break
ap = valid_pages[idx]
page_assets = asset_pages[ap]
if not page_assets:
continue
fn = _extract_figure_number(str(cap.get("text", "")))
cap_ns = _extract_figure_namespace(str(cap.get("text", "")))
fig_id = _format_figure_id(cap_ns, fn)
cap_score = score_figure_caption(
cap, nearby_media=True, caption_style_match=False, body_prose_likelihood=False
)
consumed = []
for ast in page_assets:
bid = ast.get("block_id", "")
if bid:
used_asset_page_ids.add((ap, bid))
consumed.append({
"block_id": bid,
"bbox": ast.get("bbox", [0, 0, 0, 0]),
})
unmatched_legends = [l for l in unmatched_legends if l.get("block_id") != cap.get("block_id")]
matched_figures.append({
"figure_id": fig_id,
"figure_namespace": cap_ns,
"legend_block_id": cap.get("block_id", ""),
"page": ap,
"text": str(cap.get("text", "")),
"figure_number": fn,
"matched_assets": consumed,
"asset_block_ids": [c["block_id"] for c in consumed],
"bridge_block_ids": [],
"caption_score": cap_score,
"match_score": {"score": 0.3, "decision": "matched", "evidence": ["legend_bundle_fallback"]},
"confidence": 0.3,
"flags": ["legend_bundle_match"],
})
# De-dup ambiguous_figures: remove entries whose legend_block_id
# was already matched by the bundle pass.
bundle_legend_ids = {m["legend_block_id"] for m in matched_figures if "legend_bundle_match" in m.get("flags", [])}
ambiguous_figures[:] = [a for a in ambiguous_figures if a.get("legend_block_id") not in bundle_legend_ids]
# Sequential fallback: match unmatched captions to remaining assets in reading order.
# Captions and figures often appear on different pages — humans match them by
# sequential reading order, not spatial proximity. This is a necessary tradeoff.
@ -1487,7 +1681,9 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12
):
asset = previous_page_asset
if asset is None and future_page_asset is not None:
asset = future_page_asset
fap = future_page_asset.get("page", 0) or 0
if fap in (cp, cp + 1):
asset = future_page_asset
if asset is None:
continue

View file

@ -681,13 +681,30 @@ def resolve_final_role(
"body_family_anchor",
}
if style_family in _NON_BODY_FAMILIES and style_family_authority in _STRONG_AUTHORITIES:
if style_family == "legend_like" and _looks_like_late_figure_narrative_prose(str(block.get("text") or "")):
pass # narrative prose stays body_paragraph
else:
if style_family == "legend_like":
if str(block.get("zone") or "") == "body_zone":
pass # body text with figure mention, not a caption
elif _looks_like_late_figure_narrative_prose(str(block.get("text") or "")):
pass # narrative prose stays body_paragraph
else:
_FAMILY_ROLE_MAP = {
"legend_like": "figure_caption_candidate",
}
mapped_role = _FAMILY_ROLE_MAP.get(style_family)
if mapped_role:
return RoleAssignment(
role=mapped_role,
confidence=max(current_confidence, 0.78),
evidence=[
f"late role resolution: non-body family '{style_family}' overrides body_paragraph",
f"style_family_authority={style_family_authority or 'none'}",
f"context_source={context_source}",
],
)
elif style_family in {"reference_like", "table_caption_like", "heading_like"}:
_FAMILY_ROLE_MAP = {
"reference_like": "reference_item",
"table_caption_like": "table_caption_candidate",
"legend_like": "figure_caption_candidate",
"heading_like": "section_heading",
}
mapped_role = _FAMILY_ROLE_MAP.get(style_family)