diff --git a/PROJECT-MANAGEMENT.md b/PROJECT-MANAGEMENT.md index 4f928416..0e47f304 100644 --- a/PROJECT-MANAGEMENT.md +++ b/PROJECT-MANAGEMENT.md @@ -998,6 +998,115 @@ Still open: **Result:** Later implementation steps no longer inherit already-fixed P0-P2 failures as active blockers. +--- + +## 10. 2026-06-22: Cross-Page Caption Consumption Fix + +### 10.1 Reader/Render Contract Root Cause (2026-06-22) + +**Problem:** +- Cross-page figure ownership for `2HEUD5P9` had been repaired at the inventory layer, but the reader/render chain still consumed caption blocks using the asset page instead of the true legend page. +- This left a backend contract breach: cross-page matches could still leak the original caption on `legend_page`, or at minimum keep inconsistent caption-consumption metadata. + +**Root cause:** +- `paperforge/worker/ocr_figures.py` already emits cross-page matched figures with distinct `page` and `legend_page` fields. +- `paperforge/worker/ocr_figure_reader.py::_normalize_bucket()` kept `page`, but did not preserve `legend_page` as first-class normalized data. +- `_materialize_reader_figure()` then built `consumed_caption_block_ids` using `normalized_item["page"]`, which is the asset page, not the caption page. +- `paperforge/worker/ocr_render.py` correctly suppresses captions by `(page, block_id)`, so once the reader handed it the wrong page, the contract was already broken upstream. + +**Fix:** +- Added `_caption_consumption_page()` in `ocr_figure_reader.py`. +- `_normalize_bucket()` now preserves `legend_page` from strict inventory items. +- All reader materialization paths that consume captions (`matched_figures`, grouped approximate, held/legend-only, unmatched legends) now prefer `legend_page`, falling back to `page` only when `legend_page` is absent. +- Added regression coverage: + - `tests/test_ocr_figure_reader.py::test_reader_matched_cross_page_figure_consumes_caption_on_legend_page` + - `tests/test_ocr_render.py::test_render_fulltext_markdown_suppresses_cross_page_caption_on_legend_page` + +**Result:** +- Reader payload now records cross-page caption consumption on the real legend page. +- Render path suppresses the original page-13 caption block while still allowing the figure object card to render normally. +- This fixes the highest-value contract bug without expanding scope into orphan recomputation or broader fallback redesign. + +**Test status:** +- `python -m pytest tests/test_ocr_figure_reader.py tests/test_ocr_render.py -v --tb=short` -> `16 passed` +- `python -m pytest tests/test_ocr_figures.py tests/test_ocr_figure_reader.py tests/test_ocr_render.py -q` -> `148 passed` +- Live rebuild verification: + - `python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild 2HEUD5P9` -> success + +**Remaining known issues after this fix:** +- `unmatched_assets` / orphan outputs are still recomputed too early and can go stale after later fallback ownership passes. +- Reserved cross-page settlement still picks prior/next-page owners by order rather than true compatibility when multiple candidates exist. +- Sidecar fallback still repartitions all page assets, not just unresolved/unowned ones. +- Figure/table ownership still lacks a shared consumed registry for ambiguous image-like blocks. + +**Root-cause notes for the remaining issues:** +- `unmatched_assets` is rebuilt once immediately after primary cross-page settlement, but later sidecar / group-sequential / legacy sequential fallback passes still consume assets without a final orphan recomputation. +- Reserved cross-page settlement currently chooses the first sorted compatible prior/next target rather than re-scoring multiple semantic-group candidates. +- Sidecar fallback rebuilds bands from the whole page asset list instead of just still-unowned assets, so it can theoretically reassign already-owned media. +- Figure and table matching still maintain separate ownership registries; image-like `media_asset` blocks can satisfy figure and table admission rules independently. + +### 10.2 Final Orphan Recompute Fix (2026-06-22) + +**Problem:** +- `unmatched_assets` / orphan outputs were correct immediately after primary cross-page settlement, but became stale after later ownership passes. +- This left a mismatch between actual `used_asset_page_ids` truth and the exported orphan inventory. + +**Root cause:** +- `build_figure_inventory()` rebuilt `unmatched_assets` once after Stage 1 cross-page settlement, then later sidecar / group-sequential / legacy sequential fallback passes continued consuming assets. +- No final inventory-wide recomputation happened before returning `figure_inventory`. +- As a result, assets already owned by late fallback passes could still remain in `unmatched_assets` and later be emitted as orphan outputs. + +**Fix:** +- Added `_recompute_final_unmatched_assets()` in `paperforge/worker/ocr_figures.py`. +- The helper now computes final orphan truth from: + - all original `assets` + - final `used_asset_page_ids` + - `unresolved_clusters` ownership +- `build_figure_inventory()` now runs this recomputation once at the end of the matching/fallback pipeline, just before assembling the final inventory payload. +- Updated regression expectation for cross-page asset ownership and added a new late-fallback recomputation test: + - `tests/test_ocr_figures.py::test_final_unmatched_assets_recomputed_after_late_fallback_match` + +**Result:** +- Assets consumed by late fallback passes no longer remain in `unmatched_assets`. +- Orphan output truth now matches final ownership truth rather than an intermediate pipeline snapshot. + +**Test status:** +- `python -m pytest tests/test_ocr_figures.py -k "legend_does_not_steal_offpage_asset or final_unmatched_assets_recomputed_after_late_fallback_match or legend_bundle_fallback_does_not_consume_grouped_assets" -v --tb=short` -> `3 passed` +- `python -m pytest tests/test_ocr_figures.py tests/test_ocr_figure_reader.py tests/test_ocr_render.py -q` -> `149 passed` +- Live rebuild verification: + - `python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild 2HEUD5P9` -> success + +### 10.3 Cross-Page Figure Double-Emit Fix (SAN9AYVR Figure 24) (2026-06-22) + +**Problem:** +- In `SAN9AYVR`, `Figure 24` was matched only once in inventory/reader structures, but `fulltext.md` embedded `figure_024.md` twice. +- The duplicate appeared once on the asset page and once again on the legend page. + +**Root cause:** +- This was not a matching duplication bug. +- `figure_inventory.json` contained one `matched_figures` entry for `figure_024` and `reader_figures.json` contained one `figure_024_reader` entry. +- The duplication happened in `ocr_render.py` because two independent render channels both emitted the same figure: + - legacy `figures_by_page` emitted `figure_024.md` on asset page 50 + - reader-driven emission emitted the same embed again on legend page 51 via `reader_figures_by_page` +- Render had no cross-surface dedup rule saying that if a reader figure already covers `figure_024`, the legacy `matched_figures` path must not also emit it. + +**Fix:** +- Added `_reader_covered_figure_ids()` in `ocr_render.py`. +- `render_fulltext_markdown()` now computes the set of figure ids already covered by reader figures and excludes those ids from the legacy `figures_by_page` emission map. +- Added regression coverage: + - `tests/test_ocr_render.py::test_render_fulltext_markdown_does_not_double_emit_cross_page_figure_embed` + +**Result:** +- Cross-page figures no longer emit once from the legacy figure path and again from the reader path. +- Rebuilding `SAN9AYVR` now leaves exactly one `![[render/figures/figure_024.md]]` embed in `fulltext.md`. + +**Test status:** +- `python -m pytest tests/test_ocr_render.py -k "cross_page" -v --tb=short` -> `2 passed` +- `python -m pytest tests/test_ocr_figures.py tests/test_ocr_figure_reader.py tests/test_ocr_render.py -q` -> `150 passed` +- Live rebuild verification: + - `python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild SAN9AYVR` -> success + - `fulltext.md` count of `![[render/figures/figure_024.md]]` -> `1` + ### 9.9 Task 2 — DWQQK2YB Support + Ownership Repair And Rebuild (2026-06-18) **Problem:** After 9.7, DWQQK2YB still had one real frontmatter-support routing gap and one real same-page figure ownership gap; derived assets also lagged behind the repaired code path. @@ -1700,3 +1809,134 @@ Final plan at `docs/superpowers/plans/2026-06-22-cover-page-detection-and-block- | `cc8a0f9` | `feat: convert body-zone footnotes with distinct font to callout` (Task 2) | | `4f215c9` | `feat: drop non-preproof cover page 1 via positive cover markers` (Task 1) | +### 12.15 OCR rebuild low-risk de-dup pass (2026-06-22) + +**Problem:** Derived rebuild spent avoidable time reopening PDFs during object extraction, writing structured blocks twice, reading frontmatter candidates back from a just-written JSONL file, and rescanning structured blocks during heading-prefix override in render. + +**Root cause:** Expensive resources and intermediate artifacts were owned below the correct batch boundary, so rebuild paid repeated PDF, render, write, and lookup costs even when the final outputs did not require them. + +**Fix:** Reused one lazily-opened shared PDF handle per `extract_and_write_objects()` call, preserved cache-first crop semantics, switched rebuild metadata-candidate extraction to an in-memory helper, removed the intermediate structured JSONL write, and replaced the render heading-prefix rescan with direct `bid_to_block` lookup. + +**Result:** Rebuild preserves the same output semantics while removing repeated PDF opens, repeated same-page setup work, one intermediate structured write/read cycle, and one safe O(n^2) render lookup. + +**Test status:** Added focused regression coverage for cache-first no-open behavior, open-once reuse, same-page render reuse, markdown survival on PDF-open failure, in-memory/file-based frontmatter-candidate parity, single structured-block write in rebuild, and heading-prefix parity in render. + +### 12.16 Derived rebuild span-backfill skip contract (2026-06-22) + +**Problem:** Derived rebuild reran PDF span backfill on every rebuild even when raw blocks already contained valid span enrichment. + +**Root cause:** Rebuild had no explicit validity contract for stored span enrichment, so it treated all rebuilds as requiring a fresh PDF scan. + +**Fix:** Added explicit skip/rerun policy using span-backfill version, visual-container version, current PDF fingerprint from `compute_pdf_fingerprint()`, eligible text-like coverage, recorded `span_backfill_status` fields in `meta.json`, and protected the meta write order so validity fields are only refreshed after raw persistence succeeds. + +**Result:** Rebuild can reuse valid stored raw enrichment, avoid unnecessary PDF scans, and record why skip or rerun happened. + +**Test status:** Added focused rebuild tests for eligible coverage denominator, valid skip, version/fingerprint/coverage/visual-container mismatches, unknown fingerprint invalidation, PDF-missing unavailable status, and raw-write failure validity protection. + +### 12.17 Caption-independent semantic figure grouping Stage 1A (2026-06-22) + +**Problem:** `build_figure_inventory()` still fed `candidate_groups` from caption-band-local partitions on multi-caption pages, so ledger/reservation truth was already caption-conditioned before matching. + +**Root cause:** `_build_candidate_figure_groups_from_assets()` mixed semantic grouping truth with `_partition_assets_by_caption_bands(...)` and caption-count-aware topology, letting local caption bands masquerade as ownership groups. + +**Fix:** Added caption-independent semantic grouping helpers in `paperforge/worker/ocr_figures.py`, introduced topology-by-`asset_block_ids` test seam, kept caption-band data as metadata-only assist keyed by semantic groups, and rewired `_build_candidate_figure_groups_from_assets()` so `candidate_groups` now comes from semantic groups rather than band-local partitions. + +**Result:** Same visual assets now keep stable semantic topology across caption-count changes; neutral text barriers still split continuity; ordinary same-page two-figure layouts remain separate; caption-band-local groups no longer enter the main ownership input stream. + +**Test status:** +``` +python -m pytest tests/test_ocr_figures.py -k "semantic_group_topology_uses_asset_block_ids_not_group_id or semantic_grouping_topology_is_caption_count_independent or semantic_grouping_keeps_two_visual_figures_separate or semantic_grouping_uses_caption_text_as_neutral_barrier_only or candidate_groups_do_not_include_caption_band_local_groups" -v --tb=short + -> 5 passed +``` + +### 12.18 Stage 1B grouped-asset fallback guard (2026-06-22) + +**Problem:** Stage 1B reservation and cross-page settlement were already operating on semantic groups, but the preproof `legend_bundle` fallback still consumed raw `unmatched_assets` without checking whether those assets already belonged to semantic groups. + +**Root cause:** The late old sequential fallback had a grouped-asset exclusion, but the earlier legend-bundle fallback rebuilt its own asset-page list directly from `unmatched_assets`, so grouped assets could still be stolen before the group-aware fallback ran. + +**Fix:** Added `_grouped_asset_page_ids(candidate_groups)` in `paperforge/worker/ocr_figures.py`, reused it for the old sequential fallback, and applied the same grouped-asset exclusion to the legend-bundle fallback. Added a narrow regression in `tests/test_ocr_figures.py` proving legend-bundle cannot consume assets from a semantic group. + +**Result:** Stage 1B now keeps semantic-group ownership intact across the remaining late fallback paths: reserve/same-page/cross-page continue to use semantic groups, and legacy fallback paths no longer steal grouped assets. + +**Test status:** +``` +pytest tests/test_ocr_figures.py -k "legend_bundle_fallback_does_not_consume_grouped_assets or 2heud5p9 or cross_page_backward_settlement or reservation_reserves_top_caption_on_caption_surplus_page or legacy_fallback_does_not_consume_grouped_asset" -v --tb=short + -> 5 passed + +pytest tests/test_ocr_figures.py -v --tb=short + -> 132 passed +``` + +### 12.15 Stage A Ownership Registry Mirror (2026-06-22) + +- Problem: figure ownership state in `ocr_figures.py` was mutated through scattered `used_group_ids` and `used_asset_page_ids` writes, so Stage B/C governance work had no explicit ownership seam to build on. +- Root cause: ownership lived only as ad hoc set updates inside same-page, cross-page, sidecar, bundle, and sequential figure matching paths. +- Fix: added `FigureOwnershipRegistry` in `paperforge/worker/ocr_figures.py` and routed Stage A figure-ownership commits through it while keeping the existing `used_group_ids` / `used_asset_page_ids` sets as the mirrored truth surface for current green flows. Added tests in `tests/test_ocr_figures.py` for blocked-asset reasons, conflicting figure/table ownership rejection, and registry mirror agreement with the current used-set contract. +- Result: Stage A now has an explicit ownership helper API without changing matched-figure behavior on the verified green ownership/grouped cases. +- Tests: `python -m pytest tests/test_ocr_figures.py -k "ownership_registry" -v --tb=short` -> 3 passed; `python -m pytest tests/test_ocr_figures.py -k "ownership or blocked or grouped" -v --tb=short` -> 8 passed; `python -m pytest tests/test_ocr_figures.py -v --tb=short` -> 135 passed, 1 failed (`test_caption_group_assignments_does_not_cross_page`, pre-existing failure in `paperforge/worker/ocr.py`). + +### 12.16 Stage B Local Pairing Hypothesis Surface (2026-06-22) + +- Problem: same-page figure matching only existed as immediate commit-time geometry/scoring decisions, so Stage C had no explicit local hypothesis surface to validate or defer. +- Root cause: `caption_below`, `caption_above`, and sidecar interpretation were implicit in scattered matching branches and never exposed as side-effect-free objects. +- Fix: added `_make_local_pairing_hypothesis()` and `_infer_local_pairing_mode()` in `paperforge/worker/ocr_figures.py`, collected non-authoritative `local_pairing_hypotheses` during same-page candidate evaluation, and surfaced sidecar partition hypotheses without changing current ownership commit flow. Added focused tests in `tests/test_ocr_figures.py` for pure below/above/sidecar hypothesis construction plus a mixed-page fixture proving sidecar and below hypotheses can coexist on one page. +- Result: Stage B now exposes explicit local pairing hypothesis objects while keeping ownership mutation on the existing paths. +- Tests: `python -m pytest tests/test_ocr_figures.py -k "make_local_pairing_hypothesis or mixed_local_pairing_hypotheses" -v --tb=short` -> 4 passed; `python -m pytest tests/test_ocr_figures.py -k "caption_below or caption_above or sidecar" -v --tb=short` -> 6 passed. + +### 12.17 Stage C Reservation-Aware Same-Page Commit Gate (2026-06-22) + +- Problem: reservation outranked same-page commit only by skipping reserved legends/groups before local evaluation, so reserved same-page interpretations never surfaced as explicit deferred hypotheses and one reserved no-asset path could leak into ambiguous state before cross-page settlement. +- Root cause: same-page scoring and same-page commit were still fused; reserved objects were excluded too early instead of being evaluated then withheld at commit time. +- Fix: added `_mark_hypothesis_conflict()` in `paperforge/worker/ocr_figures.py`, allowed reserved same-page candidates to be scored into `local_pairing_hypotheses`, tagged them with `reserved_same_page_commit_deferred`, and inserted a narrow defer-at-commit gate so reserved legends/groups stay in the cross-page settlement flow instead of committing or falling into early ambiguous/unmatched buckets. +- Result: reservation now outranks same-page commit through an explicit gate, while non-reserved same-page matches still commit normally and `2HEUD5P9` keeps the intended cross-page ownership behavior. +- Tests: `python -m pytest tests/test_ocr_figures.py -k "reserved_same_page_hypothesis_is_deferred_from_commit or non_reserved_same_page_pair_still_commits_normally" -v --tb=short` -> 2 passed; `python -m pytest tests/test_ocr_figures.py -k "reservation or cross_page_backward or 2HEUD5P9" -v --tb=short` -> 4 passed; `python -m pytest tests/test_ocr_figures.py tests/test_ocr_figure_reader.py tests/test_ocr_render.py -q` -> 158 passed, 1 failed (`test_caption_group_assignments_does_not_cross_page`, pre-existing failure in `paperforge/worker/ocr.py`). + +### 12.18 Stage D1 Shared Fallback Eligibility Helpers (2026-06-22) + +- Problem: late fallback paths each performed their own ownership checks, so grouped/pre-owned/blocked objects were filtered inconsistently. +- Root cause: there was no shared helper layer for fallback eligibility; each path mixed ownership logic into local control flow. +- Fix: added `_fallback_eligible_asset_page_ids()`, `_fallback_can_consume()`, and `_fallback_eligible_groups()` in `paperforge/worker/ocr_figures.py` as the shared ownership boundary layer for later fallback stages. +- Result: fallback stages now have one consistent helper surface for rejecting pre-owned, blocked, and grouped objects. +- Tests: `python -m pytest tests/test_ocr_figures.py -k "fallback and eligible" -v --tb=short` -> 3 passed. + +### 12.19 Stage D2 Sidecar Fallback Guard (2026-06-22) + +- Problem: sidecar fallback could reclaim too much page media and was willing to reinterpret a page based on narrow captions without enough local row-coupled evidence. +- Root cause: sidecar partitioning operated on broad page bands and had no explicit reclaim/eligibility layer when replacing earlier narrow-caption ownership. +- Fix: added `_caption_row_coupled_assets()` and rewired the sidecar fallback to use D1 eligibility helpers plus narrow reclaim of only the ownership it is explicitly replacing. +- Result: sidecar remains available for true narrow sidecar layouts while mixed local layouts can keep ordinary below pairs separate. +- Tests: `python -m pytest tests/test_ocr_figures.py -k "6FGDBFQN or sidecar or 3FDT9652" -v --tb=short` -> 5 passed; `python -m pytest tests/test_ocr_real_paper_regressions.py -k "6FGDBFQN or 3FDT9652" -v --tb=short` -> 2 skipped in this worktree because fixture prerequisites are absent. + +### 12.20 Stage D3 legend_bundle Fallback Guard (2026-06-22) + +- Problem: bundle fallback needed explicit confirmation that grouped assets, interrupted pages, and pre-owned asset pages remain unavailable. +- Root cause: the bundle path relied on local inline checks but had no dedicated regression coverage for those ownership and interruption boundaries. +- Fix: added focused bundle regressions covering grouped-asset exclusion, pre-owned asset-page exclusion, and body interruption rejection. +- Result: the existing narrowed bundle path remains green under the new ownership/interruption contract. +- Tests: `python -m pytest tests/test_ocr_figures.py -k "legend_bundle" -v --tb=short` -> 3 passed. + +### 12.21 Stage D4 Sequential Guard (2026-06-22) + +- Problem: sequential fallbacks needed explicit proof they do not cross ownership boundaries once earlier paths have already claimed an object. +- Root cause: earlier tests covered grouped-asset protection but not the pre-owned bare-asset case explicitly. +- Fix: added `test_sequential_fallback_skips_preowned_bare_asset()` to lock the old sequential path behind the same ownership boundary. +- Result: both group-aware sequential and old sequential now have focused regression evidence for ownership-safe behavior. +- Tests: `python -m pytest tests/test_ocr_figures.py -k "group_sequential or sequential" -v --tb=short` -> 4 passed. + +### 12.22 Stage D5 sequence_match Contract (2026-06-22) + +- Problem: `sequence_match` promotion could create a matched figure shell without carrying the real ownership payload required by downstream consumers. +- Root cause: `_promote_sequence_matches()` only checked adjacent numbering plus `asset_block_ids`, then emitted a placeholder matched figure with empty `matched_assets` and no `legend_page` / `asset_pages` / `settlement_type` contract. +- Fix: tightened `_promote_sequence_matches()` so promotion now requires concrete `matched_assets`, `asset_pages`, `legend_page`, and `page`, and emits a full contract with `asset_block_ids` and `settlement_type="sequence_match"`; otherwise the item stays ambiguous. +- Result: `sequence_match` is now either a real matched-figure payload or not promoted at all. +- Tests: `python -m pytest tests/test_ocr_figures.py tests/test_ocr_figure_reader.py -k "sequence_match" -v --tb=short` -> 3 passed. + +### 12.23 Stage E Figure/Table Ownership Conflict Surface (2026-06-22) + +- Problem: figure/table overlap had no explicit post-hoc conflict surface, so dual ownership would stay silent. +- Root cause: figure and table inventories were produced independently and never compared after matching. +- Fix: added `_collect_figure_owned_asset_ids()`, `_collect_table_owned_asset_ids()`, `_build_ownership_conflicts()`, and `attach_ownership_conflicts()` in `paperforge/worker/ocr_figures.py`, then wired the conflict attachment into `paperforge/worker/ocr.py` and `paperforge/worker/ocr_rebuild.py` after table inventory construction. +- Result: one-owner violations are now surfaced explicitly as `ownership_conflicts` instead of remaining invisible. +- Tests: `python -m pytest tests/test_ocr_figures.py -k "ownership_conflict or table" -v --tb=short` -> 1 passed. + diff --git a/docs/superpowers/plans/2026-06-22-caption-independent-figure-grouping-implementation.md b/docs/superpowers/plans/2026-06-22-caption-independent-figure-grouping-implementation.md new file mode 100644 index 00000000..81e70e08 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-caption-independent-figure-grouping-implementation.md @@ -0,0 +1,331 @@ +# Caption-Independent Figure Grouping — Stage 1A Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: use `subagent-driven-development` or `executing-plans`. + +**Goal:** Introduce caption-independent semantic grouping truth and prevent caption-band-local groups from entering the main ownership input stream. This is **Stage 1A** only. It does **not** implement or rewrite the full ledger / reservation / cross-page matching layer if those helpers are absent or unstable. + +**Primary Spec:** `docs/superpowers/specs/2026-06-22-caption-independent-figure-grouping-design.md` + +**Downstream Matching Spec Preserved:** `docs/superpowers/specs/2026-06-22-group-first-cross-page-figure-caption-matching-design.md` + +--- + +## 1. Stage Boundary + +This plan is intentionally narrower than the matching plan. + +It should be read as: + +```text +Stage 1A: +1. introduce semantic_groups +2. introduce band_assist_by_group_id +3. make the main candidate group input caption-independent +4. ensure any existing ledger/reservation input surface consumes semantic_groups +5. do not deeply rewrite same-page / cross-page matcher behavior here +``` + +It is **not**: + +```text +full matcher rewrite +``` + +If ledger / reservation / cross-page helpers are not already present in the current code branch, this plan does not invent them. That remains the downstream matching plan. + +--- + +## 2. Non-Negotiable Constraints + +- Do not let caption-band-local groups enter `candidate_groups` / ledger truth input. +- Do not let caption count (`n_legends`) alter semantic group topology. +- Do not remove neutral separator behavior from caption/text blocks. +- Do not substantially rewrite `_score_legend_to_group()` in this plan unless a targeted failing test requires it. +- Do not rewrite `build_figure_inventory()` end-to-end. +- Do not expand this plan into full reservation / cross-page implementation work. + +--- + +## 3. Compatibility Rule + +For minimal surgery, `build_figure_inventory()` may continue to use the variable name `candidate_groups`, but its value must come from caption-independent semantic grouping. + +Required interpretation: + +```text +candidate_groups = semantic_groups +``` + +Band-local groups must not be appended to that list. + +--- + +## 4. File Map + +| File | Responsibility | +|------|---------------| +| `paperforge/worker/ocr_figures.py` | Main work: semantic grouping helper, assist helper, input rewiring | +| `paperforge/worker/ocr.py` | No expected semantic change; keep render-only same-page gate unchanged | +| `paperforge/worker/ocr_objects.py` | Verify no reader/crop regressions if group payload shape changes | +| `tests/test_ocr_figures.py` | Main test coverage | + +--- + +## 5. Implementation Tasks + +### Task 1: Freeze Semantic Group Topology Contract + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Add topology comparison helper** + +Add a tiny helper used by tests or test seams: + +```python +def _semantic_group_topology(groups: list[dict]) -> set[frozenset[str]]: + return {frozenset(str(bid) for bid in g.get("asset_block_ids", [])) for g in groups} +``` + +Hard rule: + +```text +tests compare topology by asset_block_ids, not by generated group_id values +``` + +- [ ] **Step 2: Add contract comment near grouping helpers** + +Document: + +```text +semantic_groups are caption-independent +caption/text may act as neutral separators only +caption count may not change topology +``` + +--- + +### Task 2: Introduce Caption-Independent Semantic Group Builder + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Add new helper** + +Add: + +```python +def _build_semantic_figure_groups_from_assets( + assets: list[dict], + all_blocks: list[dict], + *, + page_width: float = 1200, +) -> list[dict]: + ... +``` + +Required behavior: + +1. group from the full page asset set +2. do not call `_partition_assets_by_caption_bands()` +3. do not pass `n_legends` into topology-changing behavior +4. may use `_has_text_separator(...)` as a neutral barrier +5. must return stable `asset_block_ids`, `page`, `cluster_bbox`, `group_type` + +- [ ] **Step 2: Define executable clustering contract** + +Semantic clustering should: + +1. group page assets by connected components or equivalent visual continuity logic +2. consider horizontal/vertical gap thresholds independent of caption count +3. use `_has_text_separator()` as neutral barrier +4. never create `caption_band_id` +5. never depend on ownership band identity + +Pseudocode target: + +```python +def _cluster_semantic_page_assets(page_assets, page_blocks, page_width, page_height): + parent = union_find(len(page_assets)) + for each pair a, b: + if visual_gap_too_large: + continue + if _has_text_separator(a, b, page_blocks): + continue + if same_row_or_column_continuity(a, b): + union(a, b) + return components +``` + +- [ ] **Step 3: Do not reuse caption-contaminated topology mode** + +If `_cluster_page_assets(...)` is reused, it must be wrapped or refactored so semantic mode does not consult `n_legends`. + +Allowed: + +```python +_cluster_page_assets(..., grouping_mode="semantic") +``` + +Forbidden: + +```python +_cluster_page_assets(..., n_legends=) +``` + +--- + +### Task 3: Preserve Caption-Band Logic As Assist Only + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Keep `_partition_assets_by_caption_bands()` but demote its role** + +It may remain for same-page assist only. + +- [ ] **Step 2: Add assist builder** + +Add: + +```python +def _build_caption_band_group_assist(...): + ... +``` + +or equivalent inline seam. + +Recommended output: + +```python +band_assist_by_group_id = { + group_id: { + "best_caption_band_id": str | None, + "overlap_ratio": float, + "evidence": list[str], + } +} +``` + +- [ ] **Step 3: Stage 1A restraint** + +In this plan, `band_assist_by_group_id` may be attached as metadata only. +Do not substantially rewrite `_score_legend_to_group()` unless a targeted failing test requires it. + +--- + +### Task 4: Replace Main Group Input With Semantic Groups + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Make `build_figure_inventory()` derive main group input from semantic grouping** + +Minimal-surgery path: + +```python +semantic_groups = _build_semantic_figure_groups_from_assets(...) +candidate_groups = semantic_groups +``` + +- [ ] **Step 2: Ensure band-local groups do not enter that list** + +Hard rule: + +```text +band-local groups may exist, +but must not be appended to candidate_groups +``` + +- [ ] **Step 3: If ledger/reservation helpers already exist, keep them on this input only** + +If `_build_page_ledger()`, `_build_residual_ledger()`, or reservation helpers are already present in the branch, they must consume `candidate_groups == semantic_groups`. + +If they are not present, do not invent them in this plan. + +--- + +### Task 5: Add Red/Green Tests For The Old Bug And New Invariant + +**Files:** +- Modify: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Add semantic invariant test** + +Required passing test: + +```python +new_one_caption = _build_semantic_figure_groups_from_assets(...) +new_two_captions = _build_semantic_figure_groups_from_assets(...) +assert topology(new_one_caption) == topology(new_two_captions) +``` + +- [ ] **Step 2: Add ordinary same-page multi-figure safety test** + +Required passing test: + +```text +two visually separated same-page figures do not collapse into one semantic group +``` + +- [ ] **Step 3: Add neutral separator test** + +Required passing test: + +```text +caption/text barrier can keep clusters separate without pre-assigning ownership +``` + +- [ ] **Step 4: Add optional illustrative old-bug test** + +If stable enough, add a test showing that old caption-conditioned grouping changes topology when caption count changes. + +This may be: + +1. an explicit failing old helper comparison, or +2. a comment-backed regression fixture documenting the prior behavior + +Do not make this test brittle if the old helper is being removed during refactor. + +--- + +## 6. Required Tests Matrix + +- [ ] same visual assets + caption count changes -> same semantic topology +- [ ] topology comparison uses `asset_block_ids`, not `group_id` +- [ ] caption/text barrier may split continuity without assigning ownership +- [ ] band assist exists but does not enter ledger truth +- [ ] ordinary same-page two-figure layout still yields two semantic groups or two settleable groups +- [ ] if ledger/reservation exists, it consumes semantic groups rather than caption-conditioned groups + +--- + +## 7. Verification Commands + +```bash +python -m pytest tests/test_ocr_figures.py -v --tb=short +python -m pytest tests/test_ocr_real_paper_regressions.py -v --tb=short +``` + +Optional after Stage 1B exists or branch already includes matching helpers: + +```bash +python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild 2HEUD5P9 +``` + +--- + +## 8. Exit Criteria + +This Stage 1A plan is complete only when: + +1. `semantic_groups` exist as caption-independent grouping truth +2. `candidate_groups` in the main flow come from `semantic_groups` +3. `band_assist_by_group_id` exists only as assist metadata +4. caption count / caption identity changes alone do not change semantic topology +5. neutral separator behavior is preserved +6. the plan has not silently expanded into a full matcher rewrite diff --git a/docs/superpowers/plans/2026-06-22-group-first-cross-page-figure-caption-matching-implementation.md b/docs/superpowers/plans/2026-06-22-group-first-cross-page-figure-caption-matching-implementation.md new file mode 100644 index 00000000..a2cc32ed --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-group-first-cross-page-figure-caption-matching-implementation.md @@ -0,0 +1,629 @@ +# Group-First Cross-Page Figure-Caption Matching — Stage 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox syntax. + +**Goal:** Implement the Stage 1 subset of `2026-06-22-group-first-cross-page-figure-caption-matching-design.md` with minimal surgery to the current OCR pipeline. Preserve current asset clustering and most of `build_figure_inventory()` while inserting ledger-aware reservation and primary cross-page settlement ahead of legacy fallback paths. + +**Architecture:** Keep the existing group builder (`_build_candidate_figure_groups_from_assets`), strict formal legend gate, and reader/render payload shape. Add raw/residual page ledgers, reserve surplus captions/groups before same-page settlement, skip reserved objects during same-page ownership, then run a primary cross-page settlement phase before existing preproof/group-aware/legacy fallback logic. Do not attempt a full rewrite of `build_figure_inventory()` in Stage 1. + +**Tech Stack:** Python 3.10+, pytest, PaperForge OCR workers, fixture-backed regression tests, live-vault rebuild verification. + +**Spec:** `docs/superpowers/specs/2026-06-22-group-first-cross-page-figure-caption-matching-design.md` + +--- + +## File Map + +| File | Responsibility | +|------|---------------| +| `paperforge/worker/ocr_figures.py` | Primary target. Add ledger helpers, reservation helpers, cross-page settlement phase, and output page semantics. | +| `paperforge/worker/ocr.py` | Keep `caption_group_assignments()` same-page only for render support. No cross-page semantic ownership here. | +| `paperforge/worker/ocr_objects.py` | Verify asset-page-based extraction still works with new `legend_page` / `asset_pages` fields. Modify only if necessary. | +| `tests/test_ocr_figures.py` | Main unit coverage for ledger, reservation, and cross-page settlement. | +| `tests/test_ocr_real_paper_regressions.py` | Observational regression check. Do not tighten broad gold expectations in Stage 1. | +| `tests/test_ocr_figure_reader.py` | Verify downstream reader contract remains compatible with `page`, `legend_page`, and `asset_pages`. | + +--- + +## Non-Negotiable Constraints + +- Do not rewrite `build_figure_inventory()` end-to-end in Stage 1. +- Do not change `_build_candidate_figure_groups_from_assets()` clustering behavior in this phase. +- Do not allow reservation logic to run on sidecar/narrow-caption pages in Stage 1. +- Do not let any legacy fallback consume an asset from a multi-asset group. +- Do not let reserved legends/groups re-enter same-page greedy ownership if cross-page settlement fails. +- Do not change `matched_figures.page` away from asset/crop page semantics. +- Do not introduce a heavy page classifier or ML ranking. + +--- + +## Current Flow To Preserve + +The current `build_figure_inventory()` roughly does: + +```text +collect legends/assets +-> legend dedup +-> build candidate_groups +-> same-page caption-driven matching loop +-> sidecar fallback +-> preproof legend bundle fallback +-> group-aware sequential fallback +-> old asset sequential fallback +``` + +Stage 1 must only insert new phases into this flow. It must not replace the entire flow. + +Target Stage 1 flow: + +```text +collect legends/assets +-> legend dedup +-> build candidate_groups +-> build raw ledger +-> build residual ledger seed +-> compute reserved_legend_ids / reserved_group_ids +-> same-page caption-driven matching loop (skip reserved) +-> primary cross-page settlement for reserved/residual objects +-> sidecar fallback (same-page local mode only) +-> preproof legend bundle fallback +-> group-aware sequential fallback +-> old asset sequential fallback (assets not present in any candidate group, or explicitly allowed single-asset compatibility path only) +``` + +### Stage 1 ownership compatibility note + +Current candidate group builders frequently create `single_asset` groups for ordinary figure assets. + +Therefore Stage 1 may not enforce a naive rule that old fallback sees no grouped assets at all. + +Stage 1 must choose one explicit ownership strategy: + +1. **Preferred:** upgrade group-aware fallback / primary cross-page settlement to support `single_asset` groups, then old fallback only sees assets not present in any candidate group. +2. **Compatibility fallback:** old fallback may consume `single_asset` groups through a group-aware compatibility path, but may never split or consume assets from multi-asset groups. + +This plan assumes **Option 1 unless proven infeasible**. + +Additional hard rule: + +```text +Reserved legends skipped by same-page matching must remain cross-page-eligible +even if they were never appended to unmatched_legends. +Cross-page settlement must derive reserved legends from reserved_legend_ids + legends, +not from unmatched_legends alone. +``` + +--- + +## Task 1: Lock Same-Page Render Support To Same Page Only + +**Files:** +- Modify: `paperforge/worker/ocr.py` +- Test: `tests/test_ocr_figures.py` or `tests/test_ocr_real_paper_regressions.py` if a smaller unit seam is not available + +- [ ] **Step 1: Keep `caption_group_assignments()` same-page only** + +Requirement: + +```text +caption_group_assignments is render support only. +It must never compare page-local coordinates across different pages. +``` + +Implementation: + +1. Keep/confirm the same-page gate in the caption-to-asset loop. +2. Do not add cross-page ownership logic here. +3. Treat this task as guardrail-only. If current code already satisfies same-page-only behavior, do not expand scope in `ocr.py`. + +- [ ] **Step 2: Add a targeted test** + +Test shape: + +1. page 12 has image blocks only +2. page 13 has a formal `Figure 4` caption only +3. `caption_group_assignments()` must not link page 12 image blocks to page 13 caption + +- [ ] **Step 3: Run test** + +Run the smallest relevant pytest target. + +--- + +## Task 2: Add Eligibility Helpers In `ocr_figures.py` + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Add `strong numbered legend` helper** + +Add a helper near the existing legend gate helpers: + +```python +def _is_strong_numbered_legend( + block: dict, + *, + caption_score: dict | None = None, + anchor_supported: bool | None = None, + caption_text_supported: bool | None = None, +) -> bool: + ... +``` + +Contract: + +1. figure number exists +2. existing formal legend gate passes +3. not `_is_insufficient_legend_evidence()` +4. `caption_score >= 0.4` +5. validation-first candidate must also have `anchor_supported` or `caption_text_supported` + +If `anchor_supported` / `caption_text_supported` are not passed in, the helper must compute them internally. + +- [ ] **Step 2: Split group eligibility into structural and ownership-aware helpers** + +Add a helper for ledger eligibility: + +```python +def _is_structurally_matchable_group(group: dict, *, competing_caption_pages: set[int]) -> bool: + ... + + +def _is_unowned_matchable_group( + group: dict, + *, + competing_caption_pages: set[int], + used_group_ids: set[str], + used_asset_page_ids: set[tuple], +) -> bool: + ... +``` + +Contract: + +1. `asset_block_ids` non-empty +2. structural helper does not depend on ownership +3. ownership-aware helper rejects groups already owned or partially consumed +4. group not `_non_body_media` +5. valid `cluster_bbox` +6. `page_assets` group on a competing-caption page is not matchable in Stage 1 + +- [ ] **Step 3: Unit tests** + +Cover: + +1. weak truncated legend is not strong +2. strong full `Figure N` legend is strong +3. empty group is not matchable +4. `page_assets` group on competing-caption page is not matchable + +--- + +## Task 3: Add Raw And Residual Ledger Helpers + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Add raw page ledger builder** + +Add: + +```python +def _build_page_ledger(legends: list[dict], candidate_groups: list[dict]) -> dict[int, dict]: + ... +``` + +Fields: + +1. `legend_count` +2. `numbered_legend_count` +3. `group_count` +4. `top_legend_count` +5. `bottom_legend_count` +6. `delta` + +- [ ] **Step 2: Add residual ledger builder** + +Add: + +```python +def _build_residual_ledger( + legends: list[dict], + candidate_groups: list[dict], + *, + competing_caption_pages: set[int], +) -> dict[int, dict]: + ... +``` + +Fields: + +1. `unmatched_strong_legend_count` +2. `unmatched_matchable_group_count` +3. `residual_delta` + +Stage 1 note: + +1. This is a seed residual ledger built before ownership. +2. It is used for reservation decisions. +3. It is not authoritative for post-same-page cross-page settlement. + +- [ ] **Step 3: Add surplus helpers** + +Add exact helpers from the spec: + +```python +def _residual_group_surplus(pages: list[int], residual_ledger: dict[int, dict]) -> int: + ... + + +def _residual_legend_surplus(pages: list[int], residual_ledger: dict[int, dict]) -> int: + ... +``` + +- [ ] **Step 4: Unit tests** + +Cover: + +1. balanced page -> `delta == 0` +2. caption-surplus page -> positive residual legend surplus +3. group-surplus page -> positive residual group surplus + +- [ ] **Step 5: Define post-same-page residual state contract** + +Before primary cross-page settlement, recompute or derive post-same-page residual state from live ownership. + +Required contract: + +```text +reservation uses seed residual ledger +cross-page settlement uses post-same-page unowned groups/legends +``` + +Implementation options: + +1. rebuild a second residual ledger after same-page settlement, or +2. derive residual groups/legends directly from `used_group_ids` and `used_asset_page_ids` + +--- + +## Task 4: Add Reservation Phase Before Same-Page Matching + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Add reservation helper** + +Add: + +```python +def _reserve_cross_page_objects( + legends: list[dict], + candidate_groups: list[dict], + residual_ledger: dict[int, dict], + *, + competing_caption_pages: set[int], + sidecar_pages: set[int], +) -> tuple[set[str], set[str]]: + ... +``` + +Outputs: + +1. `reserved_legend_ids` +2. `reserved_group_ids` + +Rules: + +1. caption-surplus page reserves earliest/topmost `K` strong numbered legends +2. group-surplus page reserves latest/bottommost `K` matchable groups +3. sidecar/narrow-caption pages are excluded from reservation in Stage 1 + +- [ ] **Step 1a: Define Stage 1 sidecar page detection** + +This must be computable before same-page matching. Use a static pre-settlement detector: + +```python +sidecar_pages = { + page for page, legends in legends_by_page.items() + if len(_same_page_narrow_caption_column(legends, page_width)) >= 2 +} +``` + +- [ ] **Step 2: Integrate reservation before the current caption loop** + +In `build_figure_inventory()`: + +1. compute `competing_caption_pages` +2. compute `sidecar_pages` +3. build raw ledger +4. build residual ledger seed +5. compute `reserved_legend_ids` / `reserved_group_ids` + +- [ ] **Step 3: Make the same-page loop skip reserved objects** + +Within `for legend in ordered_legends:`: + +1. if `legend_block_id in reserved_legend_ids`, do not attempt same-page ownership +2. same-page matching must also ignore groups whose `group_id` is in `reserved_group_ids` + +Important: + +Reserved objects are not failures. They are deferred to the primary cross-page settlement stage. + +- [ ] **Step 4: Unit test reservation behavior** + +Test shape: + +1. page 12 has one group +2. page 13 has two strong captions and one group +3. top caption should be reserved backward +4. bottom caption remains eligible for same-page matching + +--- + +## Task 5: Add Primary Cross-Page Settlement Before Legacy Fallback + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Add primary cross-page settlement helper** + +Add: + +```python +def _settle_cross_page_reserved_objects( + reserved_legend_ids: set[str], + reserved_group_ids: set[str], + legends: list[dict], + candidate_groups: list[dict], + structured_blocks: list[dict], + matched_figures: list[dict], + ambiguous_figures: list[dict], + unmatched_legends: list[dict], + used_group_ids: set[str], + used_asset_page_ids: set[tuple], + *, + residual_ledger: dict[int, dict], +) -> None: + ... +``` + +Rules: + +1. reserved legends look backward to `P-1`, then `P-2` +2. reserved groups look forward to `P+1`, then `P+2` +3. settlement order uses page distance first, then reading order +4. use lightweight blockers for `P±2` +5. do not use weak same-page score to override reservation +6. forward settlement may use both reserved future legends and strong unmatched future legends left unowned after same-page settlement +7. on every successful cross-page match, update both `used_group_ids` and `used_asset_page_ids` before control returns to any legacy fallback phase + +- [ ] **Step 2: Define Stage 1 interruption rules** + +Add `INTERRUPTION_ROLES` exactly as in the spec: + +```python +INTERRUPTION_ROLES = { + "section_heading", + "subsection_heading", + "sub_subsection_heading", + "table_caption", + "table_html", + "reference_heading", + "reference_item", + "backmatter_heading", + "backmatter_body", +} +``` + +Optional simple blocker: + +```text +intervening page body_paragraph count >= 3 => strong interruption +``` + +- [ ] **Step 3: Redesign `_allow_previous_page_sequential_match()` or replace it** + +Stage 1 contract: + +1. input should be a group, not a first asset +2. use group `cluster_bbox` +3. do not require caption-at-page-top heuristic +4. keep the gate conservative, but aligned to reserved settlement semantics + +It is acceptable to replace the old helper with a new reserved-settlement-specific check rather than broadening the old helper in place. + +- [ ] **Step 3a: Preserve full matched-figure schema in cross-page settlement** + +Cross-page matched entries must preserve the current same-page entry shape, then add Stage 1 fields. + +Required fields include: + +```python +page = group_page +legend_page = legend_page +asset_pages = sorted(unique_asset_pages) +settlement_type = "cross_page_backward" | "cross_page_forward" +cluster_bbox = group["cluster_bbox"] +asset_block_ids = [...] +matched_assets = [{"block_id": ..., "bbox": ...}, ...] +flags += ["cross_page_match"] +``` + +- [ ] **Step 4: Wire primary cross-page settlement before legacy fallback chain** + +Placement: + +1. after same-page caption loop completes +2. before sidecar fallback / preproof bundle / group-aware sequential fallback / old sequential fallback + +- [ ] **Step 5: Unit tests** + +Add focused tests for: + +1. backward settlement: extra caption on current page uses prior-page group +2. forward settlement: extra group on current page uses later-page caption +3. `P-2` blocked by strong interruption + +--- + +## Task 6: Prevent Legacy Fallback From Re-Stealing Ownership + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Test: `tests/test_ocr_figures.py` + +- [ ] **Step 1: Ensure reserved failures become held/ambiguous** + +If reserved settlement fails: + +1. reserved legend -> `ambiguous_figures` with `hold_reason="reserved_cross_page_no_valid_group"` +2. reserved group -> existing unresolved surface with `hold_reason="reserved_cross_page_no_valid_legend"` +3. neither may re-enter same-page ownership later + +Stage 1 mapping to existing surfaces: + +1. multi-asset reserved group failure -> `unresolved_clusters` +2. single-asset reserved group failure -> `unmatched_assets` +3. do not invent a brand-new held-group artifact in Stage 1 unless downstream code already supports it + +- [ ] **Step 2: Restrict old single-asset fallback** + +Old fallback may only consume assets that satisfy one of these: + +1. do not belong to any candidate group, or +2. belong to an explicitly allowed `single_asset` compatibility path if group-aware fallback was not upgraded + +In all cases, old fallback may never: + +1. consume any asset from a multi-asset group +2. consume any asset already owned via group ownership +3. split a group after reservation + +- [ ] **Step 3: Unit tests** + +Cover: + +1. two captions, one group -> ambiguous/held, not duplicate match +2. grouped asset may not be consumed as bare asset by old fallback + +--- + +## Task 7: Extend Output Schema Without Breaking Crop/Render + +**Files:** +- Modify: `paperforge/worker/ocr_figures.py` +- Modify only if needed: `paperforge/worker/ocr_objects.py` +- Test: `tests/test_ocr_figures.py`, `tests/test_ocr_figure_reader.py` + +- [ ] **Step 1: Add output fields to matched figure entries** + +Required fields: + +```python +{ + "page": int, # primary asset/crop/render page + "asset_pages": list[int], + "legend_page": int, + "settlement_type": str, +} +``` + +Stage 1 guarantee: + +```text +matched_figures.page == primary asset/crop page +``` + +This requirement applies to all matched figure entries, not just cross-page ones. + +For same-page entries: + +```text +legend_page = legend page +asset_pages = [entry["page"]] +settlement_type = "same_page" +``` + +- [ ] **Step 2: Verify object extraction continues to crop from asset page** + +If `ocr_objects.py` assumes `page` is the crop page, keep that contract unchanged. + +- [ ] **Step 3: Reader/output tests** + +Add one test asserting: + +1. cross-page match has `page=asset_page` +2. `legend_page=caption_page` +3. `asset_pages=[...]` +4. `settlement_type="cross_page_backward"` or `cross_page_forward` + +--- + +## Task 8: 2HEUD5P9 Verification + +**Files:** +- Test/reference: live vault rebuild output + +- [ ] **Step 1: Add a focused regression-style test fixture if feasible** + +If a small synthetic test can cover the ownership pattern, prefer that first. + +- [ ] **Step 2: Rebuild 2HEUD5P9 in the vault and inspect figure inventory** + +Verify: + +1. Figure 4 uses page 12 assets +2. Figure 5 uses page 13 assets +3. Figure 4 does not consume page 13 Figure 5 panels +4. page 12 Figure 4 panels are no longer orphaned because of page-13 same-page theft + +- [ ] **Step 3: Spot-check rendered outputs** + +Check: + +1. `render/figures/figure_004.md` +2. `render/figures/figure_005.md` +3. `assets/figures/figure_004.jpg` +4. `assets/figures/figure_005.jpg` + +--- + +## Acceptance Test Matrix + +- [ ] `page12 group + page13 Fig4 caption + page13 Fig5 group + page13 Fig5 caption` -> Figure 4 backward, Figure 5 same-page +- [ ] same-page one-to-one case remains unchanged +- [ ] caption-surplus reserve works +- [ ] group-surplus forward works +- [ ] two captions cannot claim one group +- [ ] old fallback cannot split grouped assets +- [ ] single-asset cross-page case still works after reservation/fallback refactor +- [ ] cross-page match emits `page`, `asset_pages`, `legend_page`, `settlement_type` + +--- + +## Suggested Verification Commands + +```bash +python -m pytest tests/test_ocr_figures.py -v --tb=short +python -m pytest tests/test_ocr_figure_reader.py -v --tb=short +python -m pytest tests/test_ocr_real_paper_regressions.py -v --tb=short +python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild 2HEUD5P9 +``` + +--- + +## Exit Criteria + +Stage 1 is complete only when: + +1. current same-page normal figure behavior remains stable +2. reserved captions/groups are excluded from same-page greed +3. primary cross-page settlement runs before legacy fallback +4. legacy fallback cannot break group ownership +5. 2HEUD5P9 Figure 4/5 ownership is corrected diff --git a/docs/superpowers/plans/2026-06-22-local-pairing-and-fallback-governance-execution.md b/docs/superpowers/plans/2026-06-22-local-pairing-and-fallback-governance-execution.md new file mode 100644 index 00000000..6c7f8b2f --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-local-pairing-and-fallback-governance-execution.md @@ -0,0 +1,523 @@ +# Local Pairing And Fallback Governance — Full Execution Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: use `subagent-driven-development` or `executing-plans`. + +**Purpose:** This is the executable companion to the umbrella roadmap at `docs/superpowers/plans/2026-06-22-local-pairing-and-fallback-governance-implementation.md`. Unlike the roadmap, this document is intended to be actionable end-to-end by another agent in one development run, while still keeping internal stages explicit and test-gated. + +**Primary governance spec:** `docs/superpowers/specs/2026-06-22-local-pairing-and-fallback-governance-design.md` + +**Upstream specs that must already hold in the branch before continuing:** +- `docs/superpowers/specs/2026-06-22-caption-independent-figure-grouping-design.md` +- `docs/superpowers/specs/2026-06-22-group-first-cross-page-figure-caption-matching-design.md` + +**Roadmap this executes:** `docs/superpowers/plans/2026-06-22-local-pairing-and-fallback-governance-implementation.md` + +--- + +## 0. Hard Execution Rules + +1. Execute in order. Do not jump ahead. +2. After each stage, run the listed verification before proceeding. +3. If a stage fails its exit criteria, stop and fix that stage before moving on. +4. Update `PROJECT-MANAGEMENT.md` immediately after each landed fix group. +5. Do not invent new broad scoring. Use ownership guards and contract completion first. +6. Do not silently expand scope beyond the stage being executed. + +--- + +## 1. Files In Scope + +Primary: +- `paperforge/worker/ocr_figures.py` + +Secondary / contract surfaces: +- `paperforge/worker/ocr_figure_reader.py` +- `paperforge/worker/ocr_render.py` +- `paperforge/worker/ocr_tables.py` + +Tests: +- `tests/test_ocr_figures.py` +- `tests/test_ocr_figure_reader.py` +- `tests/test_ocr_render.py` +- `tests/test_ocr_real_paper_regressions.py` + +Docs / log: +- `PROJECT-MANAGEMENT.md` + +Live verification targets: +- `D:/L/OB/Literature-hub` +- `2HEUD5P9` +- `SAN9AYVR` + +Additional observational paper families to keep in mind while writing tests / guards: +- `3FDT9652` +- `6FGDBFQN` +- `8VB9ZVQG` +- `24YKLTHQ` + +--- + +## 2. Stage 0 — Preflight / Dependency Gate + +### Goal +Confirm the branch already contains the two prerequisite foundations so this plan does not accidentally re-implement them. + +### Required checks + +- [ ] Confirm semantic grouping is already caption-independent + - Evidence expected: `_build_semantic_figure_groups_from_assets()` exists and feeds `candidate_groups` +- [ ] Confirm reserve-before-greedy-commit foundations already exist + - Evidence expected: ledger/residual/reserved helpers exist and same-page loop skips reserved legends/groups +- [ ] Confirm previously repaired cross-page rendering still works + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "semantic_grouping or reservation or cross_page_backward" -v --tb=short +python -m pytest tests/test_ocr_figure_reader.py tests/test_ocr_render.py -k "cross_page" -v --tb=short +``` + +### If Stage 0 fails +Produce a short dependency report containing: +1. missing foundation +2. evidence / failing test / missing helper +3. prerequisite plan to run next + +Hard rule: + +```text +If Stage 0 fails, stop. Do not modify production code in this run. +``` + +### Exit criteria +- [ ] All prerequisite seams exist and tests are green + +--- + +## 3. Stage A — Ownership Registry Mirror (Behavior-Preserving) + +### Goal +Introduce explicit ownership/state surfaces without changing `matched_figures` output yet. + +### Required implementation + +- [ ] Add a registry/helper API instead of scattered dict/set mutation +- [ ] Keep current `used_group_ids` / `used_asset_page_ids` behavior intact +- [ ] Mirror existing used sets rather than replacing them in this stage + +Suggested API surface: + +```python +class FigureOwnershipRegistry: + def reserve_group(self, group_id: str, *, reason: str) -> None: ... + def match_group(self, group: dict, *, owner_id: str, owner_family: str) -> None: ... + def mark_assets_owned(self, asset_ids: list[tuple[int, str]], *, owner_id: str, owner_family: str) -> None: ... + def block_asset(self, asset_id: tuple[int, str], *, reason: str) -> None: ... + def can_consume_group(self, group: dict) -> bool: ... + def can_consume_assets(self, asset_ids: list[tuple[int, str]]) -> bool: ... + def transition_reserved_to_held(self, group_id: str, *, reason: str) -> None: ... +``` + +### Required state rules + +Group-level transitions: + +```text +unowned -> reserved +unowned -> matched +unowned -> ambiguous +reserved -> matched +reserved -> ambiguous +reserved -> held +reserved -> unowned only via explicit audited release +matched -> terminal +ambiguous/held -> terminal unless explicit audited release +``` + +Asset-level transitions: + +```text +unowned -> reserved_by_group +unowned -> owned_by_figure +unowned -> owned_by_table +unowned -> blocked(reason) +reserved_by_group -> owned_by_figure +reserved_by_group -> held/blocked(reason) +owned_by_figure / owned_by_table -> terminal +``` + +### Tests to add + +- [ ] blocked asset always carries explicit reason +- [ ] one asset cannot be both figure-owned and table-owned in registry state +- [ ] registry mirror agrees with current used sets on existing green cases + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "ownership or blocked or grouped" -v --tb=short +python -m pytest tests/test_ocr_figures.py -v --tb=short +``` + +### `PROJECT-MANAGEMENT.md` +Add a new subsection for Stage A documenting: +- problem +- root cause +- fix +- result +- tests + +### Exit criteria +- [ ] behavior-preserving: current figure ownership outputs unchanged on existing green tests +- [ ] ownership/state seams exist and are test-backed + +--- + +## 4. Stage B — Local Pairing Hypothesis Surface (Behavior-Preserving) + +### Goal +Represent `caption_below`, `caption_above`, and `caption_sidecar` as explicit local hypotheses without changing ownership output yet. + +### Required implementation + +- [ ] Add hypothesis helper, for example: + +```python +def _make_local_pairing_hypothesis( + legend: dict, + group: dict, + *, + mode: str, + local_score: float, + evidence: list[str] | None = None, + conflicts: list[str] | None = None, +) -> dict: + ... +``` + +- [ ] Generate local hypotheses side-effect free +- [ ] Do not update `used_group_ids` or `used_asset_page_ids` here +- [ ] Do not yet replace commit behavior + +### Tests to add + +Synthetic-first: +- [ ] `caption_below` case +- [ ] `caption_above` case +- [ ] single-figure sidecar case +- [ ] mixed page with below + sidecar hypotheses + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "caption_below or caption_above or sidecar" -v --tb=short +python -m pytest tests/test_ocr_figures.py -v --tb=short +``` + +### `PROJECT-MANAGEMENT.md` +Add a new subsection for Stage B. + +### Exit criteria +- [ ] behavior-preserving: no ownership output change yet +- [ ] explicit hypothesis objects exist and are test-backed + +--- + +## 5. Stage C — Reservation-Aware Same-Page Commit Gate + +### Goal +Install the minimal commit gate that separates local hypothesis evaluation from ownership mutation. + +### Required implementation + +- [ ] Keep current same-page candidate scoring order +- [ ] Convert selected candidate into a local hypothesis object +- [ ] Before appending to `matched_figures` / updating used sets, call registry commit gate +- [ ] If legend/group is reserved, defer instead of commit + +Deferred reserved objects rule: + +```text +Deferred reserved legends/groups remain in reserved/residual flow. +Do not append them to ambiguous_figures merely because same-page commit was withheld. +Only settlement failure may transition them to ambiguous/held. +``` + +### Tests to add + +- [ ] `2HEUD5P9`-style reserve-before-greedy-commit +- [ ] plausible same-page hypothesis withheld by reservation +- [ ] non-reserved same-page pair still commits normally + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "reservation or cross_page_backward or 2HEUD5P9" -v --tb=short +python -m pytest tests/test_ocr_figures.py tests/test_ocr_figure_reader.py tests/test_ocr_render.py -q +python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild 2HEUD5P9 +``` + +### `PROJECT-MANAGEMENT.md` +Add a new subsection for Stage C. + +### Exit criteria +- [ ] reserve outranks same-page commit on imbalanced pages +- [ ] no regression on `2HEUD5P9` + +--- + +## 6. Stage D1 — Shared Fallback Eligibility Helpers + +### Goal +Introduce common eligibility helpers before touching any individual fallback behavior. + +### Required implementation + +- [ ] Add helpers such as: + +```python +_fallback_eligible_asset_page_ids(...) +_fallback_eligible_groups(...) +_fallback_can_consume(...) +``` + +- [ ] No broad scoring changes in this stage + +### Tests to add + +- [ ] each helper rejects pre-owned objects +- [ ] grouped assets remain unavailable to fallback unless explicitly allowed + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "fallback and eligible" -v --tb=short +``` + +### Exit criteria +- [ ] helper layer exists and is green + +--- + +## 7. Stage D2 — Sidecar Fallback Guard + +### Goal +Fix the highest-risk ownership-stealing fallback first. + +### Required implementation + +- [ ] Sidecar consumes only still-unowned local assets/groups +- [ ] Sidecar may not repartition the whole page after ownership exists +- [ ] Use row-coupled local conditions; do not trigger from “two narrow captions” alone + +### Tests to add + +- [ ] true sidecar case (`6FGDBFQN`-like) +- [ ] non-sidecar multi-column page must not false-trigger (`3FDT9652`-like) +- [ ] mixed page with one sidecar pair and one ordinary below pair +- [ ] pre-owned object test: sidecar cannot consume already matched/reserved assets + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "6FGDBFQN or sidecar or 3FDT9652" -v --tb=short +python -m pytest tests/test_ocr_real_paper_regressions.py -k "6FGDBFQN or 3FDT9652" -v --tb=short +``` + +### `PROJECT-MANAGEMENT.md` +Add a new subsection for Stage D2. + +### Exit criteria +- [ ] sidecar no longer steals whole-page owned assets +- [ ] true sidecar still works + +--- + +## 8. Stage D3 — `legend_bundle` Fallback Guard + +### Goal +Keep preproof / ancient-layout recovery narrow and ownership-safe. + +### Required implementation + +- [ ] Grouped assets remain unavailable to bundle fallback +- [ ] Interruption rules remain honored +- [ ] Bundle consumes only eligible post-legend unowned asset/group pages + +### Tests to add + +- [ ] grouped assets are skipped +- [ ] body/table/reference interruption blocks bundle fallback +- [ ] pre-owned object test for bundle path + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "legend_bundle" -v --tb=short +``` + +### `PROJECT-MANAGEMENT.md` +Add a new subsection for Stage D3. + +### Exit criteria +- [ ] bundle path respects ownership and interruption constraints + +--- + +## 9. Stage D4 — `group_sequential` / old `sequential` Guard + +### Goal +Keep both sequential layers from crossing ownership boundaries. + +### Required implementation + +- [ ] `group_sequential` may consume unowned semantic groups, including `single_asset` groups +- [ ] It may not split multi-asset groups or override stronger ownership +- [ ] Old `sequential` remains restricted to bare assets outside semantic groups, or explicit single-asset compatibility path + +### Tests to add + +- [ ] `group_sequential` does not split multi-asset groups +- [ ] old sequential cannot consume grouped assets +- [ ] pre-owned object tests for both paths + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "group_sequential or sequential" -v --tb=short +``` + +### `PROJECT-MANAGEMENT.md` +Add a new subsection for Stage D4. + +### Exit criteria +- [ ] both sequential paths obey ownership boundaries + +--- + +## 10. Stage D5 — `sequence_match` Contract + +### Goal +Make `sequence_match` either a real contract or not a match. + +### Required implementation + +- [ ] Promotion may not invent ownership from caption order alone +- [ ] Promoted entries must include full matched-figure contract: + +```text +page +legend_page +asset_pages +matched_assets +asset_block_ids +settlement_type +``` + +- [ ] If that payload cannot be formed, stay ambiguous/held instead of promoting + +### Tests to add + +- [ ] no promotion without real asset ownership payload +- [ ] promoted sequence matches carry full fields + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py tests/test_ocr_figure_reader.py -k "sequence_match" -v --tb=short +``` + +### `PROJECT-MANAGEMENT.md` +Add a new subsection for Stage D5. + +### Exit criteria +- [ ] sequence_match is either a real contract or not a match + +--- + +## 11. Stage E — Figure/Table Ownership Conflict Surface + +### Goal +Add global figure/table conflict surfacing, starting conservatively with post-hoc conflict reporting. + +### Required implementation + +- [ ] collect figure-owned asset IDs +- [ ] collect table-owned asset IDs +- [ ] emit `ownership_conflict` records instead of silent duplication + +Suggested helpers: + +```python +def _collect_figure_owned_asset_ids(figure_inventory: dict) -> set[tuple[int, str]]: ... +def _collect_table_owned_asset_ids(table_inventory: dict) -> set[tuple[int, str]]: ... +def _build_ownership_conflicts(...) -> list[dict]: ... +``` + +### Tests to add + +- [ ] conflict surface exists when one block is claimed by both figure and table +- [ ] no silent duplicate ownership on mixed pages + +### Verification commands + +```bash +python -m pytest tests/test_ocr_figures.py -k "ownership_conflict or table" -v --tb=short +``` + +### `PROJECT-MANAGEMENT.md` +Add a new subsection for Stage E. + +### Exit criteria +- [ ] one-owner rule has at least a post-hoc explicit conflict surface + +--- + +## 12. Full Test Strategy + +### Synthetic-first + +- [ ] local `caption_below` hypothesis +- [ ] local `caption_above` hypothesis +- [ ] local sidecar hypothesis +- [ ] mixed page with sidecar + below hypotheses +- [ ] reserve-before-commit case +- [ ] reserved failure transition to ambiguous/held or audited release +- [ ] each fallback rejects pre-owned objects +- [ ] `sequence_match` requires complete ownership payload +- [ ] figure/table conflict surface emitted + +### Real-paper observational second + +- [ ] `3FDT9652` +- [ ] `6FGDBFQN` +- [ ] `8VB9ZVQG` +- [ ] `24YKLTHQ` +- [ ] `2HEUD5P9` +- [ ] `SAN9AYVR` + +--- + +## 13. Verification Commands + +```bash +python -m pytest tests/test_ocr_figures.py -v --tb=short +python -m pytest tests/test_ocr_figure_reader.py tests/test_ocr_render.py -v --tb=short +python -m pytest tests/test_ocr_real_paper_regressions.py -v --tb=short +python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild 2HEUD5P9 +python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild SAN9AYVR +``` + +--- + +## 14. Exit Criteria + +This execution plan is complete only when: + +1. Stage 0 passed and documented its evidence +2. Stage A and B remained behavior-preserving scaffolding stages +3. reservation-aware commit gate exists before major fallback rewiring +4. each fallback class obeys the same ownership guards +5. `sequence_match` is either a real contract or not a match +6. figure/table one-owner rule has at least a post-hoc conflict surface +7. canonical layout families remain stable under verification diff --git a/docs/superpowers/plans/2026-06-22-local-pairing-and-fallback-governance-implementation.md b/docs/superpowers/plans/2026-06-22-local-pairing-and-fallback-governance-implementation.md new file mode 100644 index 00000000..a1c4a0bf --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-local-pairing-and-fallback-governance-implementation.md @@ -0,0 +1,474 @@ +# Local Pairing And Fallback Governance — Roadmap + +> **For agentic workers:** REQUIRED SUB-SKILL: use `subagent-driven-development` or `executing-plans`. + +**Goal:** Implement the governance model from `2026-06-22-local-pairing-and-fallback-governance-design.md` in small, reversible stages. This document is an umbrella roadmap, not a single all-at-once execution plan. + +**Primary Spec:** `docs/superpowers/specs/2026-06-22-local-pairing-and-fallback-governance-design.md` + +**Depends on:** +- `docs/superpowers/specs/2026-06-22-caption-independent-figure-grouping-design.md` +- `docs/superpowers/specs/2026-06-22-group-first-cross-page-figure-caption-matching-design.md` + +--- + +## 1. Roadmap Positioning + +This document must not be treated as: + +```text +one execution task for one agent in one pass +``` + +It should be treated as: + +```text +umbrella implementation roadmap +``` + +Execution should happen as multiple smaller plans / PR-sized stages. + +The key reason is blast radius: + +```text +ownership registry ++ local pairing hypotheses ++ reservation-aware commit ++ sidecar / bundle / sequential rewiring ++ table conflict handling +``` + +is too much for one direct execution pass. + +--- + +## 2. Stage Structure + +The roadmap is intentionally split into: + +```text +Stage 0: Preflight / dependency gate +Stage A: Ownership registry mirror (behavior-preserving) +Stage B: Local pairing hypothesis surface (behavior-preserving) +Stage C: Reservation-aware same-page commit gate +Stage D1: Shared fallback eligibility helpers +Stage D2: Sidecar fallback guard +Stage D3: legend_bundle fallback guard +Stage D4: group_sequential / old sequential guard +Stage D5: sequence_match contract +Stage E: figure/table ownership conflict surface +``` + +Stages A and B are scaffolding stages. +They must not materially change matched ownership output. + +--- + +## 3. Stage 0 — Preflight / Dependency Gate + +**Purpose:** make sure this roadmap is not being applied on a branch that still lacks the prerequisite foundations. + +Before any implementation from this roadmap: + +- [ ] verify semantic grouping is already caption-independent +- [ ] verify ledger / reservation / cross-page settlement helpers already exist in the current branch +- [ ] run current regression checks for `2HEUD5P9` and `SAN9AYVR` +- [ ] if either foundation is missing, stop and execute the prerequisite plan first + +If Stage 0 fails, produce a short dependency report containing: + +1. the missing foundation +2. the evidence / failing test / missing helper +3. the prerequisite plan that should run next + +Hard rule: + +```text +if Stage 0 fails, do not modify production code in this roadmap run +``` + +Required checks: + +```text +semantic_groups exist and feed candidate_groups +reserve-before-greedy-commit behavior already exists +cross-page figure render dedup still passes +``` + +If this gate fails, do not continue with later stages. + +--- + +## 4. Stage A — Ownership Registry Mirror (Behavior-Preserving) + +**Purpose:** introduce explicit ownership/state surfaces without yet changing ownership decisions. + +**Files likely touched:** +- `paperforge/worker/ocr_figures.py` +- maybe helper files if extraction is needed +- `tests/test_ocr_figures.py` + +### Requirements + +- [ ] add ownership registry/helper API rather than scattered dict mutation +- [ ] state changes must go through helper calls, not direct branch-local mutation +- [ ] preserve current output behavior + +Mirror rule: + +```text +In Stage A, the registry mirrors existing used_group_ids / used_asset_page_ids. +Those existing sets remain source-compatible for behavior. +Registry assertions may compare against them, but Stage A must not require replacing them yet. +``` + +Suggested surface: + +```python +class FigureOwnershipRegistry: + def reserve_group(self, group_id: str, *, reason: str) -> None: ... + def match_group(self, group: dict, *, owner_id: str, owner_family: str) -> None: ... + def mark_assets_owned(self, asset_ids: list[tuple[int, str]], *, owner_id: str, owner_family: str) -> None: ... + def block_asset(self, asset_id: tuple[int, str], *, reason: str) -> None: ... + def can_consume_group(self, group: dict) -> bool: ... + def can_consume_assets(self, asset_ids: list[tuple[int, str]]) -> bool: ... + def transition_reserved_to_held(self, group_id: str, *, reason: str) -> None: ... +``` + +### State transition table to enforce + +Group-level: + +```text +unowned -> reserved +unowned -> matched +unowned -> ambiguous +reserved -> matched +reserved -> ambiguous +reserved -> held +reserved -> unowned only through explicit audited_release +matched -> terminal +ambiguous/held -> terminal unless explicitly audited_release +``` + +Asset-level: + +```text +unowned -> reserved_by_group +unowned -> owned_by_figure +unowned -> owned_by_table +unowned -> blocked(reason) +reserved_by_group -> owned_by_figure +reserved_by_group -> held/blocked(reason) +owned_by_figure / owned_by_table -> terminal +``` + +### Tests + +- [ ] blocked assets always carry explicit reasons +- [ ] one asset cannot be both figure-owned and table-owned in registry state +- [ ] behavior-preserving: matched ownership output is unchanged vs current branch + +--- + +## 5. Stage B — Local Pairing Hypothesis Surface (Behavior-Preserving) + +**Purpose:** introduce explicit hypothesis objects without changing ownership commit behavior yet. + +**Files likely touched:** +- `paperforge/worker/ocr_figures.py` +- `tests/test_ocr_figures.py` + +### Requirements + +- [ ] local pairing modes produce explicit hypothesis objects +- [ ] hypothesis generation is side-effect free +- [ ] no ownership output change yet + +Minimum helper: + +```python +def _make_local_pairing_hypothesis( + legend: dict, + group: dict, + *, + mode: str, + local_score: float, + evidence: list[str] | None = None, + conflicts: list[str] | None = None, +) -> dict: + ... +``` + +Hard rule: + +```text +Stage B only introduces hypothesis objects and tests. +It must not change ownership commit behavior except through a later explicit Stage C commit gate. +``` + +### Tests + +- [ ] synthetic `caption_below` case +- [ ] synthetic `caption_above` case +- [ ] synthetic single-figure sidecar case +- [ ] mixed page with below + sidecar hypotheses +- [ ] behavior-preserving: matched ownership output unchanged vs current branch + +--- + +## 6. Stage C — Reservation-Aware Same-Page Commit Gate + +**Purpose:** install the minimal commit gate that separates local evaluation from ownership mutation. + +**Files likely touched:** +- `paperforge/worker/ocr_figures.py` +- `tests/test_ocr_figures.py` + +### Minimal path + +- [ ] keep current same-page candidate scoring order +- [ ] convert the selected candidate into a local hypothesis object +- [ ] before appending to `matched_figures` / updating used sets, call the registry commit gate +- [ ] if legend/group is reserved, defer instead of commit + +Deferred reserved object rule: + +```text +deferred reserved legends/groups remain in the existing reserved/residual flow used by cross-page settlement. +do not append them to ambiguous_figures merely because same-page commit was withheld. +only settlement failure may transition them to ambiguous/held. +``` + +This stage should not become a full same-page matcher rewrite. + +### Required rule + +```text +reservation state outranks same-page commit on imbalanced pages +``` + +### Tests + +- [ ] `2HEUD5P9`-style reserve-before-greedy-commit +- [ ] plausible same-page hypothesis exists but is withheld due to reservation +- [ ] non-reserved same-page hypothesis still commits normally + +--- + +## 7. Stage D1 — Shared Fallback Eligibility Helpers + +**Purpose:** unify the basic ownership checks before changing fallback behavior. + +**Files likely touched:** +- `paperforge/worker/ocr_figures.py` +- `tests/test_ocr_figures.py` + +- [ ] add helpers such as: + +```python +_fallback_eligible_asset_page_ids(...) +_fallback_eligible_groups(...) +_fallback_can_consume(...) +``` + +- [ ] no broad scoring changes +- [ ] no fallback behavior expansion yet + +### Tests + +- [ ] each helper rejects pre-owned objects +- [ ] grouped assets remain unavailable to late fallback unless explicitly allowed + +--- + +## 8. Stage D2 — Sidecar Fallback Guard + +**Purpose:** fix the most dangerous ownership-stealing fallback first. + +**Files likely touched:** +- `paperforge/worker/ocr_figures.py` +- `tests/test_ocr_figures.py` +- maybe `tests/test_ocr_real_paper_regressions.py` + +### Requirements + +- [ ] sidecar consumes only still-unowned local assets/groups +- [ ] it may not repartition the whole page after ownership exists +- [ ] it must not rely only on "two narrow captions"; use row-coupled local conditions + +Substage isolation rule: + +```text +Stage D2 may modify only sidecar fallback plus shared eligibility helpers. +Do not opportunistically adjust legend_bundle, sequential, or sequence_match logic in the same substage. +``` + +### Tests + +- [ ] true sidecar case (`6FGDBFQN`-like) +- [ ] non-sidecar multi-column page must not false-trigger (`3FDT9652`-like) +- [ ] mixed page with one sidecar pair and one ordinary below pair +- [ ] pre-owned object test: sidecar cannot consume already matched/reserved assets + +--- + +## 9. Stage D3 — `legend_bundle` Fallback Guard + +**Purpose:** keep preproof/ancient-layout recovery narrow and ownership-safe. + +### Requirements + +- [ ] grouped assets remain unavailable to bundle fallback +- [ ] interruptions remain honored +- [ ] fallback consumes only eligible post-legend unowned asset/group pages + +Substage isolation rule: + +```text +Stage D3 may modify only legend_bundle fallback plus shared eligibility helpers. +Do not opportunistically adjust sidecar, sequential, or sequence_match logic in the same substage. +``` + +### Tests + +- [ ] grouped assets are skipped +- [ ] strong body/table/reference interruption blocks bundle fallback +- [ ] pre-owned object test for bundle path + +--- + +## 10. Stage D4 — `group_sequential` / old `sequential` Guard + +**Purpose:** keep the two sequential layers from crossing ownership boundaries. + +### Requirements + +- [ ] `group_sequential` may consume unowned semantic groups, including `single_asset` groups +- [ ] it may not split multi-asset groups or override stronger ownership +- [ ] old `sequential` remains restricted to bare assets outside semantic groups, or an explicitly allowed single-asset compatibility path + +Substage isolation rule: + +```text +Stage D4 may modify only group_sequential / old sequential plus shared eligibility helpers. +Do not opportunistically adjust sidecar, legend_bundle, or sequence_match logic in the same substage. +``` + +### Tests + +- [ ] `group_sequential` does not split multi-asset groups +- [ ] old sequential cannot consume grouped assets +- [ ] pre-owned object tests for both paths + +--- + +## 11. Stage D5 — `sequence_match` Contract + +**Purpose:** make `sequence_match` either a real contract or not a match. + +### Requirements + +- [ ] promotion may not invent ownership from caption order alone +- [ ] promoted entries must include full matched-figure contract: + +```text +page +legend_page +asset_pages +matched_assets +asset_block_ids +settlement_type +``` + +- [ ] if that payload cannot be formed, stay ambiguous/held instead of promoting + +Substage isolation rule: + +```text +Stage D5 may modify only sequence_match promotion plus any shared contract helpers strictly required for it. +Do not opportunistically adjust sidecar, legend_bundle, or sequential fallback logic in the same substage. +``` + +### Tests + +- [ ] no promotion without real asset ownership payload +- [ ] promoted sequence matches carry full fields + +--- + +## 12. Stage E — Figure/Table Ownership Conflict Surface + +**Purpose:** add global figure/table conflict surfacing, preferably as a separate later sub-plan. + +This stage should begin with post-hoc conflict emission, not immediate reordering of table/figure match pipelines. + +### Minimum first step + +- [ ] collect figure-owned asset IDs +- [ ] collect table-owned asset IDs +- [ ] emit `ownership_conflict` records instead of silent duplication + +Suggested helpers: + +```python +def _collect_figure_owned_asset_ids(figure_inventory: dict) -> set[tuple[int, str]]: ... +def _collect_table_owned_asset_ids(table_inventory: dict) -> set[tuple[int, str]]: ... +def _build_ownership_conflicts(...) -> list[dict]: ... +``` + +### Tests + +- [ ] conflict surface exists when one block is claimed by both figure and table +- [ ] no silent duplicate ownership on mixed pages + +--- + +## 13. Test Strategy + +### Synthetic-first + +- [ ] local `caption_below` hypothesis +- [ ] local `caption_above` hypothesis +- [ ] local sidecar hypothesis +- [ ] mixed page with sidecar + below hypotheses +- [ ] reserve-before-commit case +- [ ] reserved failure transition to ambiguous/held or audited release +- [ ] each fallback rejects pre-owned objects +- [ ] `sequence_match` requires complete ownership payload +- [ ] figure/table conflict surface emitted + +### Real-paper observational second + +- [ ] `3FDT9652` +- [ ] `6FGDBFQN` +- [ ] `8VB9ZVQG` +- [ ] `24YKLTHQ` +- [ ] `2HEUD5P9` +- [ ] `SAN9AYVR` + +--- + +## 14. Verification Commands + +```bash +python -m pytest tests/test_ocr_figures.py -v --tb=short +python -m pytest tests/test_ocr_figure_reader.py tests/test_ocr_render.py -v --tb=short +python -m pytest tests/test_ocr_real_paper_regressions.py -v --tb=short +python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild 2HEUD5P9 +python -m paperforge --vault "D:/L/OB/Literature-hub" ocr rebuild SAN9AYVR +``` + +--- + +## 15. Exit Criteria + +This roadmap is complete only when: + +1. ownership state is explicit and changed only through helper APIs +2. Stage A and B are behavior-preserving scaffolding layers +3. reservation-aware commit gate exists before major fallback rewiring +4. each fallback class obeys the same ownership guards +5. `sequence_match` is either a real contract or not a match +6. figure/table one-owner rule has at least a post-hoc conflict surface +7. canonical layout families remain stable under verification diff --git a/docs/superpowers/specs/2026-06-22-caption-independent-figure-grouping-design.md b/docs/superpowers/specs/2026-06-22-caption-independent-figure-grouping-design.md new file mode 100644 index 00000000..aaad34b2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-caption-independent-figure-grouping-design.md @@ -0,0 +1,556 @@ +# Caption-Independent Figure Grouping Design + +> **Date:** 2026-06-22 +> **Status:** Proposed +> **Scope:** Redefine the upstream figure-group construction layer so figure groups are built from visual assets first, independent of caption-band partitioning. This spec preserves the existing ledger / reservation / same-page / cross-page matching direction discussed in `2026-06-22-group-first-cross-page-figure-caption-matching-design.md` and changes only the grouping truth feeding that matcher. + +This spec also supersedes the earlier Stage 1 assumption that existing grouping/clustering could remain untouched while only the matching layer changed. The upstream grouping layer must now be corrected first, or the downstream matcher will continue to consume caption-conditioned pseudo-groups. + +--- + +## 1. Problem Statement + +The current OCR figure pipeline mixes two different responsibilities into one step: + +1. **Figure grouping**: determine which visual assets belong to the same visual figure unit +2. **Caption settlement**: decide which caption owns which figure unit + +Today, `paperforge/worker/ocr_figures.py::_build_candidate_figure_groups_from_assets()` does not build a caption-independent figure stream on multi-caption pages. +When `n_legends >= 2`, it first runs `_partition_assets_by_caption_bands(...)`, then clusters inside each caption band. + +Current effective flow: + +```text +raw assets +-> caption-band partitioning +-> per-band asset clustering +-> candidate groups +-> ledger / reserve / settlement +``` + +This violates the design intent already established for cross-page matching: + +```text +raw assets +-> figure groups +-> page ledger +-> reserve +-> same-page settlement +-> cross-page settlement +``` + +The difference matters because the current grouping step is already conditioned on caption location. +Once assets are pre-assigned to captions by band, the downstream ledger sees a page as locally self-consistent even when the true figure ownership is cross-page. + +### Canonical failure: 2HEUD5P9 Figure 4 / Figure 5 + +Observed layout: + +1. **Page 12** contains Figure 4 panels as visual assets +2. **Page 13** contains Figure 4 caption, Figure 5 assets, and Figure 5 caption + +What the current grouping layer does: + +1. Page 13 has 2 legends +2. Assets on page 13 are partitioned into caption bands for Figure 4 and Figure 5 +3. Each band produces local candidate groups +4. Figure 4 therefore appears to already have same-page groups on page 13 +5. Page 12 groups are orphaned before the cross-page matcher even gets a fair chance + +This is not primarily a matching bug anymore. +It is an upstream truth bug: **group formation is already contaminated by caption heuristics**. + +--- + +## 2. Core Principle + +Figure grouping must be a standalone visual truth layer. + +### Hard rule + +**Captions may validate or rank groups, but captions may not define the initial group boundaries.** + +Implications: + +1. A multi-caption page may still contain multiple figure groups, but those groups must emerge from the visual asset field first +2. Caption-band logic is not allowed to be the first splitter that determines group identity +3. The ledger must operate on caption-independent groups, not caption-conditioned bands + +### Separator carve-out + +Caption independence does **not** mean caption blindness. + +Caption and text blocks may still be used as **neutral visual separators / barriers** when deciding whether two asset regions remain visually continuous. + +Allowed: + +```text +Use caption/text blocks to answer: +"Are these two asset regions physically interrupted by intervening text?" +``` + +Forbidden: + +```text +Use caption identity to answer: +"Which caption already owns this asset before groups exist?" +``` + +In short: + +```text +caption/text may block continuity, +caption identity may not pre-assign ownership bands +``` + +--- + +## 3. Goals + +This grouping spec must: + +1. Preserve the previously discussed matching direction: ledger -> reserve -> same-page settlement -> cross-page settlement +2. Make figure groups arise from visual adjacency and figure-internal signals first +3. Prevent multi-caption pages from becoming falsely self-balanced purely because assets were pre-divided by caption position +4. Keep band / local caption geometry available as a downstream validation signal for same-page tie-breaking +5. Minimize disruption to the existing matching pipeline in Stage 1 + +--- + +## 4. Non-Goals + +This spec does not: + +1. Rewrite the downstream ledger / reservation / settlement design +2. Introduce ML or learned page classification +3. Solve every orphan rendering issue +4. Replace existing formal caption detection +5. Require a full end-to-end rewrite of `build_figure_inventory()` in one step + +--- + +## 5. Architectural Split + +The figure pipeline should explicitly separate two layers. + +### 5.1 Semantic grouping layer + +Inputs: + +1. `figure_asset` +2. `media_asset` +3. optional figure-internal textual markers such as `figure_inner_text` + +Behavior: + +1. Build visual figure groups independent of caption bands +2. Determine group boundaries from visual adjacency, spacing, and figure-internal continuity +3. Allow neutral layout separators, including caption/text blocks, to block continuity where they physically interrupt the visual field +4. Produce stable page-local figure candidates for ownership + +### 5.2 Settlement-assist layer + +Inputs: + +1. semantic groups from 5.1 +2. captions / legends +3. optional caption-band geometry + +Behavior: + +1. run page ledger / residual ledger +2. reserve surplus legends or groups +3. perform same-page settlement +4. perform cross-page settlement +5. use band / geometry only as supporting evidence, not as upstream truth + +--- + +## 6. Current Conflict To Remove + +Current `_build_candidate_figure_groups_from_assets()` behavior on `n_legends >= 2`: + +```python +if n_legends >= 2: + band_map = _partition_assets_by_caption_bands(page_legends, page_media, page_height) + partitions = ... +else: + partitions = [(None, list(page_media))] + +for band_id, partition in partitions: + clusters = _cluster_page_assets(partition, ...) +``` + +This means a multi-caption page is grouped differently solely because captions are present. + +That behavior is the conflict. + +The grouping layer should not ask: + +```text +Which caption band does this asset sit near? +``` + +It should ask first: + +```text +Which neighboring assets does this asset visually belong with? +``` + +Only after those groups exist may caption-band evidence be used to check whether a local caption/group association is plausible. + +### Hidden second conflict: caption-count-aware clustering + +The contamination is not limited to `_partition_assets_by_caption_bands(...)`. + +Current `_cluster_page_assets(...)` also takes `n_legends` and changes merge behavior based on caption count. +That means even if band partitioning were removed, semantic grouping could still remain caption-conditioned if it reuses the same caption-count-sensitive topology rules. + +Required rule: + +```text +Semantic grouping must not use `n_legends` or caption count to decide whether visual assets belong together. +``` + +Implementation implication: + +1. semantic grouping must bypass current caption-count-dependent clustering behavior, or +2. current clustering must be wrapped/refactored with an explicit semantic mode that does not consult caption count + +--- + +## 7. Proposed Grouping Model + +### 7.1 Build semantic groups per page from the full page asset set + +For each page: + +1. collect all figure-like assets on the page +2. cluster them visually using the existing asset clustering logic as the primary base +3. allow figure-internal continuity signals to keep assets together when they belong to one composite figure +4. allow caption/text blocks as neutral separators only +5. do not split by caption bands during this phase +6. do not let caption count alter semantic topology during this phase + +The result is a caption-independent group stream: + +```python +semantic_groups = build_semantic_figure_groups(page_assets, page_blocks) +``` + +### 7.2 Optional same-page assist groups + +Band partitioning may still exist, but only as a secondary assist stream: + +```python +band_local_groups = build_caption_band_local_groups(page_legends, page_assets, page_blocks) +``` + +Rules: + +1. `band_local_groups` may help same-page validation or tie-breaking +2. `band_local_groups` may not be the group stream used by ledger or reservation +3. `band_local_groups` may not redefine semantic group identity +4. `band_local_groups` must be attachable back to semantic groups as assist metadata, not as replacement groups + +### 7.3 Required relation between semantic groups and band assist + +If band-local evidence is used downstream, it must be attachable back to semantic groups explicitly. + +Accepted shapes include embedded assist metadata: + +```python +{ + "group_id": str, + "page": int, + "asset_block_ids": list[str], + "cluster_bbox": list[int], + "group_type": str, + "assist": { + "caption_band_ids": list[str], + "band_overlap": {str: float}, + }, +} +``` + +or a separate side map: + +```python +band_assist_by_group_id = { + "group_0001": { + "best_caption_band_id": str | None, + "overlap_ratio": float, + "evidence": list[str], + } +} +``` + +Hard rule: + +```text +band assist must be keyed by semantic group identity; +it may not create an alternative group universe that ledger consumes +``` + +--- + +## 8. Grouping Invariants + +### 8.1 Semantic groups are caption-independent + +Changing caption positions or caption count may not change semantic grouping of the same asset field, except where captions are only used as weak validation metadata after groups already exist. + +### 8.2 Same page, same visual field, same semantic groups + +If a page's visual assets are unchanged, adding or removing a second caption may not cause the page to be repartitioned into a different semantic group topology. + +Equivalent acceptance phrasing: + +```text +same visual assets + different caption count +=> same semantic group topology +``` + +Semantic topology comparison must use sorted sets of `asset_block_ids` per group, not generated `group_id` values. + +Suggested comparison form: + +```python +{frozenset(g["asset_block_ids"]) for g in semantic_groups} +``` + +### 8.3 Settlement consumes groups, not bands + +Ledger and reservation must only count semantic groups. + +### 8.4 Band logic is evidence, not truth + +Caption-band assignment may: + +1. down-rank a same-page candidate +2. prefer one already-valid same-page candidate over another +3. support sidecar handling + +Caption-band assignment may not: + +1. create a semantic group boundary +2. suppress a semantic group from ledger accounting +3. make a page look self-balanced before reserve logic runs + +Caption/text movement may affect grouping only when it physically changes neutral separator/barrier geometry; caption identity, caption count, or ownership-band assignment alone may not change semantic topology. + +### 8.5 No aggregate page group as semantic truth on competing-caption pages + +An aggregate "all assets on the page" helper may still exist for debugging or weak fallback, but on competing-caption pages it may not be treated as semantic ledger truth. + +Reason: + +```text +semantic grouping on multi-caption pages must prefer visually separated clusters +over collapsing the whole page into one ownership truth object +``` + +--- + +## 9. Required Contract Changes + +### 9.1 `build_figure_inventory()` + +The grouping/matching boundary becomes: + +```text +collect legends/assets +-> build semantic_groups (caption-independent) +-> build raw/residual ledger from semantic_groups +-> compute reservations +-> same-page settlement (may consult band_local_groups) +-> cross-page settlement +-> legacy fallback with ownership guards +``` + +### 9.2 `_build_candidate_figure_groups_from_assets()` + +This function currently mixes grouping truth with caption-band partitioning. + +Required Stage 1 refactor direction: + +1. either split it into two helpers: + - `build_semantic_figure_groups(...)` + - `build_caption_band_local_groups(...)` +2. or change its contract so its primary return is semantic groups and any band-local derivation is explicit side metadata + +Additional requirement: + +```text +semantic grouping path may not call caption-band partitioning, +and may not pass caption count into topology-changing clustering logic +``` + +### 9.3 Ledger inputs + +`_build_page_ledger()` and `_build_residual_ledger()` must consume semantic groups only. + +They may not consume caption-band-local groups. + +### 9.4 Matching plan update requirement + +Any implementation plan that assumes "keep current clustering unchanged" must be updated. + +New Stage 1 baseline: + +```text +1. build semantic_groups first +2. ledger / reservation consume semantic_groups only +3. same-page settlement may consult band assist +4. legacy fallback still obeys ownership guards +``` + +--- + +## 10. Interaction With Existing Matching Spec + +This spec does **not** replace the matching spec. +It corrects the upstream assumption needed to make that spec valid. + +### Matching logic that remains valid + +The following ideas still stand: + +1. reserve before same-page matching +2. skip reserved objects during same-page settlement +3. reserved legends look backward +4. reserved groups look forward +5. band / geometry can help local same-page validation + +### What changes + +Only this: + +```text +the objects being counted and reserved must come from semantic grouping, +not from band-conditioned grouping +``` + +And therefore the earlier Stage 1 matching plan must be interpreted as depending on this grouping correction first, or be amended to include it directly. + +--- + +## 11. 2HEUD5P9 Expected Behavior Under This Grouping Design + +Desired upstream truth: + +1. Page 12 semantic assets form a Figure 4 visual group +2. Page 13 semantic assets form the local figure group(s) actually visible on page 13 +3. Page 13 should not appear self-balanced merely because Figure 4 caption was used to create a Figure 4 band on page 13 + +Desired downstream effect: + +1. Ledger sees a real mismatch between page 12 groups and page 13 captions/groups +2. Figure 4 can be reserved and matched backward +3. Figure 5 keeps local page 13 ownership +4. Page 12 Figure 4 panels no longer remain orphaned because of a false same-page local match on page 13 + +--- + +## 12. Migration Strategy + +### Stage 1: separate truth from evidence + +1. keep the downstream matching logic already discussed +2. introduce caption-independent semantic grouping +3. keep caption-band grouping only as same-page assist metadata +4. switch ledger / residual ledger inputs to semantic groups +5. ensure semantic grouping still respects neutral separator barriers and does not regress ordinary same-page multi-figure pages + +### Stage 2: tighten same-page assist usage + +1. explicitly document where band-local evidence may influence scoring +2. remove any remaining places where band-local groups silently behave like semantic groups + +### Stage 3: remove dead assumptions + +1. clean up any dead `page_assets`-style code paths that are no longer produced by the actual group builder +2. make group-type contracts explicit and test-backed + +--- + +## 13. Required Tests + +### 13.1 Caption count does not change semantic topology + +Given the same visual asset set: + +1. case A: one caption +2. case B: two captions + +Expectation: + +```text +semantic group asset topology is unchanged +``` + +### 13.2 Caption-band assist does not enter ledger + +Expectation: + +```text +band_local_groups may exist, +but _build_page_ledger() and _build_residual_ledger() count semantic_groups only +``` + +### 13.3 Ordinary same-page two-figure page does not collapse + +Expectation: + +```text +removing band partition from semantic truth must not collapse +two visually separated same-page figures into one semantic group +``` + +### 13.4 Caption as separator remains allowed + +Expectation: + +```text +two asset clusters physically separated by intervening caption/text +may remain separate because the text acts as a neutral barrier, +not because the caption pre-owns either side +``` + +### 13.5 2HEUD5P9 upstream truth check + +Expectation: + +```text +page 13 Figure 4 caption must not create a fake page-13 Figure-4 semantic group +before reserve logic runs +``` + +--- + +## 14. Acceptance Criteria + +This grouping redesign is accepted only if all are true: + +1. multi-caption pages are not semantically pre-split by caption bands before ledger construction +2. changing caption geometry alone does not redefine semantic group identity +3. ledger / residual ledger operate on caption-independent groups +4. same-page band logic remains available as a downstream validation aid only +5. 2HEUD5P9 no longer fails because Figure 4 already appears locally satisfied on page 13 before reserve logic runs +6. existing ordinary same-page multi-figure layouts do not regress in local ownership quality +7. semantic grouping still permits text/caption blocks to behave as neutral visual separators +8. caption count alone does not change semantic group topology + +--- + +## 15. Implementation Notes + +The key discipline is simple: + +```text +group first from vision, +caption second for ownership +``` + +If captions help define groups, the matcher is already biased before it starts. +That is exactly the conflict this spec removes. diff --git a/docs/superpowers/specs/2026-06-22-group-first-cross-page-figure-caption-matching-design.md b/docs/superpowers/specs/2026-06-22-group-first-cross-page-figure-caption-matching-design.md new file mode 100644 index 00000000..2b2a7865 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-group-first-cross-page-figure-caption-matching-design.md @@ -0,0 +1,729 @@ +# Group-First Cross-Page Figure-Caption Matching Design + +> **Date:** 2026-06-22 +> **Status:** Proposed +> **Scope:** Replace caption-first figure matching with group-first, page-ledger-based figure/caption settlement across same-page and cross-page layouts +> **Supersedes:** None directly. Refines the matching stage after `2026-06-21-global-distance-clustering-figure-merge.md` + +--- + +## 1. Problem Statement + +The current figure pipeline can already recognize both sides of the problem: + +1. **Figure-side objects**: `figure_asset`, `media_asset`, `figure_inner_text`, clustered figure-like regions +2. **Caption-side objects**: formal numbered legends such as `Figure 4. ...` + +The recurring failures are no longer primarily about missing detection. They are about **matching order and ownership**. + +Current failure mode: + +```text +caption -> pick nearby asset(s) -> consume asset(s) -> maybe merge later +``` + +This creates three structural problems: + +| Problem | Why it happens | Consequence | +|---------|----------------|-------------| +| **Caption steals the wrong same-page figure** | Same-page spatial scoring is attempted before cross-page settlement | A caption on page N+1 can consume another figure's panels on page N+1 instead of its own panels on page N | +| **Cross-page figures are treated as fallback exceptions** | Previous-page matching is a narrow heuristic gate, not a first-class layout mode | Full-page figures with captions on the next page become fragile and layout-dependent | +| **Merge happens too late** | Matching is still effectively asset-first, while grouping is only partial pre-processing | Two captions can compete over fragments of what should have been one figure group | + +The 2HEUD5P9 Figure 4 / Figure 5 case is a canonical example: + +1. Page 12 contains Figure 4 panels as visual assets +2. Page 13 contains Figure 4 caption, Figure 5 assets, and Figure 5 caption +3. Same-page scoring favors page 13 assets for Figure 4 because they are spatially close +4. The real Figure 4 visual group on page 12 loses to the wrong same-page candidate + +This is not a one-off bug. It exposes the wrong matching philosophy. + +--- + +## 2. Core Design Principle + +The matching unit must be a **stable figure group**, not a raw asset. + +The processing order must be: + +```text +raw assets +-> figure groups +-> page ledger +-> reserve surplus captions/groups +-> same-page settlement among non-reserved objects +-> cross-page settlement for reserved/residual objects +-> weak tie-breaks +``` + +Not: + +```text +caption -> raw asset match -> consume -> try to merge/fallback later +``` + +### Hard rule + +**Group formation is upstream truth. Matching may consume groups, but matching may not redefine grouping.** + +Implications: + +1. A caption never matches a bare asset directly if that asset belongs to a figure group +2. A figure group may be matched by at most one caption +3. Two captions may never "share" a figure group unless the system explicitly emits a held/ambiguous state rather than a match + +--- + +## 3. Goals + +This design must: + +1. Make cross-page figure/caption layouts a normal supported pattern, not a heuristic exception +2. Make grouping happen before caption ownership +3. Prevent a caption from stealing another figure's same-page panels when page-level count balance indicates a cross-page pairing +4. Support both directions: + - extra caption on current page -> look backward for figure group + - extra figure group on current page -> look forward for caption +5. Keep the logic independent from body prose analysis as much as possible + +--- + +## 4. Non-Goals + +This design does not: + +1. Rebuild OCR block detection from scratch +2. Replace the existing asset clustering algorithm in this phase +3. Solve all orphan rendering UX issues +4. Relax legend recognition standards +5. Introduce ML ranking or learned layout classification + +--- + +## 5. New Matching Model + +### 5.1 Separate the two streams + +The pipeline should explicitly build and carry two independent streams: + +1. **Figure groups** +2. **Legends** + +These streams are settled against each other later. + +#### Figure group + +A figure group is the stable visual object created before legend matching. + +Minimum fields: + +```python +{ + "group_id": str, + "pages": list[int], + "asset_block_ids": list[str], + "panel_label_block_ids": list[str], + "cluster_bbox_by_page": dict[int, list[int]], + "union_bbox": list[int], + "asset_count": int, + "group_type": str, +} +``` + +#### Legend + +Legend is any formal numbered figure caption that passes the existing formal-legend gate. + +Minimum fields: + +```python +{ + "legend_block_id": str, + "page": int, + "figure_number": int, + "figure_namespace": str, + "bbox": list[int], + "text": str, +} +``` + +--- + +## 6. Page Ledger Model + +Instead of classifying pages into many semantic types, use a simple **page ledger** over figure-only signals. + +For each page, compute a raw ledger first: + +```python +{ + "page": int, + "legend_count": int, + "numbered_legend_count": int, + "group_count": int, + "top_legend_count": int, + "bottom_legend_count": int, + "delta": legend_count - group_count, +} +``` + +This raw ledger is only a **signal**, not a direct settlement instruction. + +### 6.1 Residual ledger + +Settlement must operate on a second ledger built from strong, eligible residual objects: + +```python +{ + "page": int, + "unmatched_strong_legend_count": int, + "unmatched_matchable_group_count": int, + "residual_delta": unmatched_strong_legend_count - unmatched_matchable_group_count, +} +``` + +Why two ledgers are required: + +1. Raw page counts can include weak/truncated legends +2. Raw page counts can include unresolved or unmatchable groups +3. Settlement decisions must be based on **eligible unowned objects**, not raw detection totals + +### 6.2 Surplus helpers + +The reservation phase must not leave surplus arithmetic implicit. + +Required helper contracts: + +```python +def residual_group_surplus(pages: list[int], residual_ledger: dict[int, dict]) -> int: + return sum( + max(0, residual_ledger[p]["unmatched_matchable_group_count"] - residual_ledger[p]["unmatched_strong_legend_count"]) + for p in pages if p in residual_ledger + ) + + +def residual_legend_surplus(pages: list[int], residual_ledger: dict[int, dict]) -> int: + return sum( + max(0, residual_ledger[p]["unmatched_strong_legend_count"] - residual_ledger[p]["unmatched_matchable_group_count"]) + for p in pages if p in residual_ledger + ) +``` + +These helpers define `K` in the reservation rules. The implementation may optimize them, but it may not silently reinterpret surplus arithmetic. + +### Interpretation + +| Condition | Meaning | +|-----------|---------| +| `delta == 0` | Same-page settlement should usually be sufficient | +| `delta > 0` | More captions than figure groups on this page; at least some captions should look backward | +| `delta < 0` | More figure groups than captions on this page; at least some groups should look forward | + +This ledger is intentionally minimal. It only reasons over the figure/caption stream, not over general body-page taxonomy. + +--- + +## 7. Matching Phases + +### Phase 1: Group Formation + +Existing group formation remains upstream truth. + +Requirements: + +1. Run visual clustering first +2. Assign every asset to at most one group +3. Do not let later caption matching split or reshape groups +4. Preserve `caption_band_id` as metadata only; it may guide settlement but may not override group identity + +### 7.0 Eligibility contracts + +Before reservation or settlement, both streams must be filtered to **eligible** objects. + +#### Strong numbered legend + +`strong numbered legend` means: + +1. `figure_number` exists +2. Existing formal legend gate passes +3. Not `_is_insufficient_legend_evidence()` +4. `caption_score >= 0.4` + +If the legend entered through validation-first recovery, it must additionally satisfy at least one strong support signal: + +1. `anchor_supported`, or +2. `caption_text_supported` + +Otherwise it may remain held/ambiguous, but it must not participate in reservation arithmetic. + +#### Matchable group + +`matchable group` means: + +1. `asset_block_ids` is non-empty +2. group is not already owned +3. group is not `_non_body_media` +4. group has a valid `cluster_bbox` +5. group type is not currently suppressed by a higher-priority local mode + +For Stage 1, `page_assets` groups on competing-caption pages are not matchable groups for ledger settlement. + +### Phase 2: Reserve Before Same-Page Settlement + +This is the critical rule missing from caption-first matching. + +Same-page settlement may not run naively on every caption/group on a page. + +If page `P` has figure/caption imbalance, some objects must be reserved for cross-page settlement first. + +#### Rule A: caption-surplus reservation + +If `residual_delta(P) > 0`: + +1. Compute `K = min(residual_delta(P), residual_group_surplus(P-1, P-2))` +2. Reserve the earliest/topmost `K` strong numbered legends on page `P` +3. Reserved legends may not participate in same-page ownership in this phase + +Interpretation: + +```text +If the page has more strong captions than matchable groups, +some captions are presumed to belong to earlier pages. +``` + +#### Rule B: group-surplus reservation + +If `residual_delta(P) < 0`: + +1. Compute `K = min(-residual_delta(P), residual_legend_surplus(P+1, P+2))` +2. Reserve the latest/bottommost `K` unowned groups on page `P` +3. Reserved groups may not be consumed by same-page legends in this phase + +Interpretation: + +```text +If the page has more groups than strong captions, +some groups are presumed to belong to later pages. +``` + +### Phase 3: Same-Page Settlement + +Within a page: + +1. Match legends to groups in natural reading order +2. Only group-level objects participate +3. Resolve obvious one-to-one same-page cases first +4. Reserved legends/groups are excluded from this phase +5. Do not consume unmatched residual groups or legends yet + +Same-page settlement is allowed to use spatial scoring, but only among page-local groups and only after reservation has been applied. + +### 7.1 Same-page settlement contract + +Same-page settlement must explicitly distinguish the following modes: + +1. **Obvious one-to-one page** + - one strong legend + - one matchable group + - no prior/next page residual debt +2. **Balanced multi-pair page** + - equal strong legends and matchable groups + - no prior/next page residual debt +3. **Caption-surplus page** + - reserve surplus legends before same-page matching +4. **Group-surplus page** + - reserve surplus groups before same-page matching +5. **Sidecar/narrow-caption page** + - use local caption-band/sidecar partition mode + +#### Stage 1 restriction for sidecar pages + +To avoid destabilizing already-repaired local sidecar behavior, Stage 1 implementation should exclude sidecar/narrow-caption pages from ledger reservation. + +That means: + +1. run local sidecar settlement first on those pages +2. only if sidecar mode fails completely may a later phase escalate to cross-page logic + +This prevents same-page geometric score from reintroducing the original stealing bug. + +### Phase 4: Cross-Page Settlement + +After reservation and same-page settlement, use residual ledger state. + +#### Rule A: extra caption(s) on page P + +If page `P` has residual unmatched legends and `residual_delta(P) > 0`: + +1. Search unmatched groups on `P-1`, then `P-2` +2. Prefer nearest prior page with residual unmatched groups +3. Match reserved legends in page order, not by raw same-page score +4. If previous-page matching fails, the legend remains unresolved/ambiguous; it may not re-enter same-page stealing + +Interpretation: + +```text +caption surplus means the page owes figure groups from earlier pages +``` + +#### Rule B: extra figure group(s) on page P + +If page `P` has residual unmatched groups and `residual_delta(P) < 0`: + +1. Search unmatched legends on `P+1`, then `P+2` +2. Prefer nearest later page with residual unmatched legends +3. Match reserved groups in page order, not by raw same-page score + +Interpretation: + +```text +group surplus means the page owes captions from later pages +``` + +### 7.2 Lightweight cross-page blockers + +The design should avoid full body-page classification, but it still needs light blockers for longer jumps. + +Rules: + +1. `P-1` / `P+1` cross-page settlement is generally allowed if residual ledger supports it +2. `P-2` / `P+2` settlement requires no strong interruption on the intervening page +3. Strong interruption means section heading / table ownership / reference zone / dense body prose that clearly breaks figure continuation + +For Stage 1, `strong interruption` should be implemented conservatively via explicit roles, not a new body classifier. + +```python +INTERRUPTION_ROLES = { + "section_heading", + "subsection_heading", + "sub_subsection_heading", + "table_caption", + "table_html", + "reference_heading", + "reference_item", + "backmatter_heading", + "backmatter_body", +} +``` + +Optional simple prose blocker for Stage 1: + +```text +intervening page body_paragraph count >= 3 => treat as strong interruption +``` + +This is not body-prose ranking. It is only a blocker to prevent long-distance ledger misuse. + +### Phase 5: Weak Tie-Breaking + +Only after same-page and cross-page settlement have both run: + +1. Use geometric score to break ties among still-valid candidates +2. Never use weak score to override already-balanced page-ledger settlement + +This means **direction is decided before score**. + +--- + +## 8. Settlement Invariants + +These are hard constraints. + +### 8.1 One group, one owner + +```text +one figure group -> at most one matched legend +one legend -> at most one matched figure group +``` + +If two captions compete for one group: + +1. Do not greedily consume it +2. Emit conflict/ambiguous state +3. Leave ownership unresolved rather than silently stealing it + +### 8.2 Merge precedes ownership + +No caption is allowed to match a member asset of a multi-asset group while bypassing the group. + +### 8.3 Cross-page settlement is symmetric + +Backward and forward settlement must both be supported: + +1. `legend surplus -> look backward` +2. `group surplus -> look forward` + +No special-case asymmetry should remain in the architecture. + +--- + +## 9. Settlement Invariants + +These are hard constraints. + +### 9.1 One group, one owner + +```text +one figure group -> at most one matched legend +one legend -> at most one matched figure group +``` + +If two captions compete for one group: + +1. Do not greedily consume it +2. Emit conflict/ambiguous state +3. Leave ownership unresolved rather than silently stealing it + +### 9.2 Merge precedes ownership + +No caption is allowed to match a member asset of a multi-asset group while bypassing the group. + +### 9.3 Cross-page settlement is symmetric + +Backward and forward settlement must both be supported: + +1. `legend surplus -> look backward` +2. `group surplus -> look forward` + +No special-case asymmetry should remain in the architecture. + +### 9.4 Failed reservation settlement must not backslide + +If a reserved object fails cross-page settlement: + +1. it may enter `ambiguous_figures` / held state +2. it may remain in unmatched output for audit visibility +3. it may **not** re-enter same-page greedy ownership later in the pipeline + +Required outputs: + +```text +reserved legend failed -> hold_reason="reserved_cross_page_no_valid_group" +reserved group failed -> hold_reason="reserved_cross_page_no_valid_legend" +``` + +This rule is what prevents the old fallback chain from silently re-stealing ownership. + +--- + +## 10. Required Changes to Current Pipeline + +### 10.1 `caption_group_assignments()` + +This function is for same-page rendering support only. + +Contract change: + +1. It must only compare captions and assets on the same page +2. It must not participate in cross-page semantic ownership + +Reason: + +Page-local coordinates are not comparable across pages. + +### 10.2 `build_figure_inventory()` + +This becomes the single semantic truth surface for figure/caption ownership. + +Contract changes: + +1. Main same-page match consumes only figure groups +2. Reservation happens before same-page match on imbalanced pages +3. Residual unmatched legends and groups enter ledger-based settlement +4. Sequential fallback is renamed conceptually to **cross-page settlement** and becomes a primary stage, not a weak afterthought + +Stage 1 implementation constraint: + +```text +Do not rewrite build_figure_inventory() end-to-end. +Keep current group formation and current output shape. +Insert reservation + primary cross-page settlement ahead of legacy fallback. +``` + +### 10.3 Existing previous-page gate + +`_allow_previous_page_sequential_match()` is currently too shape-specific. + +Current weakness: + +1. It relies on caption-at-page-top heuristics +2. It checks the first asset bbox, not the group bbox +3. It behaves like a patch gate rather than a general cross-page policy + +Required redesign: + +1. Input should be a **group**, not a single first asset +2. Use group bbox / page ledger residuals +3. Remove assumptions that only page-top captions may match backward + +### 10.4 Matched figure page semantics + +The current output schema overloads `page`. + +Cross-page matching requires explicit separation of asset page and legend page. + +Required output fields: + +```python +{ + "page": int, # primary asset/crop/render page + "asset_pages": list[int], # pages carrying matched visual group assets + "legend_page": int, # page carrying formal caption + "settlement_type": str, # same_page | cross_page_backward | cross_page_forward +} +``` + +Why: + +1. crop/render needs the asset page +2. reader placement and audit need the legend page +3. cross-page matches cannot safely overload one field to mean both + +Backward-compatible Stage 1 interpretation: + +```text +matched_figures.page == primary asset/crop page +``` + +This must remain true for object extraction. + +--- + +## 11. Proposed Matching Contract + +### 11.1 Inputs + +```python +groups = build_figure_groups(assets, panel_labels) +legends = collect_formal_legends(blocks) +raw_ledger = build_page_ledger(groups, legends) +residual_ledger = build_residual_ledger(groups, legends) +``` + +### 11.2 Settlement + +```python +reserved_legends, reserved_groups = reserve_cross_page_objects(groups, legends, residual_ledger) +same_page_matches, residual_legends, residual_groups = settle_same_page( + groups, legends, residual_ledger, + reserved_legends=reserved_legends, + reserved_groups=reserved_groups, +) +cross_page_matches = settle_cross_page( + residual_groups, + residual_legends, + residual_ledger, + reserved_legends=reserved_legends, + reserved_groups=reserved_groups, +) +final_matches = same_page_matches + cross_page_matches +``` + +### 11.2a Stage 1 implementation mapping + +Stage 1 should map this contract onto the current pipeline with minimal disruption: + +1. keep current `_build_candidate_figure_groups_from_assets()` +2. build raw/residual ledger from current legends + candidate groups +3. compute `reserved_legend_ids` / `reserved_group_ids` before the current caption loop +4. current same-page caption loop skips reserved objects +5. run primary cross-page settlement before current legacy fallback chain +6. legacy single-asset fallback may only consume assets that do not belong to any group + +### 11.3 Priority + +```text +1. group formation +2. raw page ledger +3. residual ledger +4. reserve surplus captions/groups +5. same-page settlement among non-reserved objects +6. cross-page settlement for reserved/residual objects +7. weak geometric tie-breaks +8. unresolved -> ambiguous/held, never silent theft +``` + +--- + +## 12. 2HEUD5P9 Expected Behavior Under This Design + +### Observed counts + +```text +page 12: groups > legends +page 13: legends > groups +``` + +### Natural settlement + +1. Raw ledger identifies page 13 caption surplus +2. Reservation reserves the earliest/topmost surplus legend on page 13: Figure 4 +3. Same-page settlement excludes reserved Figure 4 and consumes Figure 5 against page 13 visual groups +4. Cross-page settlement matches reserved Figure 4 backward to page 12 figure group(s) + +Expected outcome: + +1. Figure 4 does **not** consume page 13 Figure 5 panels +2. Figure 4 no longer leaves page 12 panels as orphan media +3. Figure 5 keeps ownership of its page 13 assets + +--- + +## 13. Migration Strategy + +### Stage 1 + +Keep existing clustering. Add raw ledger + residual ledger + reservation before same-page settlement. + +Do not replace the entire caption loop in this stage. The goal is to insert ledger-aware reservation and primary cross-page settlement while preserving current clustering and current render payloads. + +### Stage 2 + +Extract settlement helpers: + +```python +collect_figure_legends() +build_figure_groups() +build_page_ledger() +build_residual_ledger() +reserve_cross_page_objects() +settle_same_page() +settle_cross_page() +``` + +### Stage 3 + +Limit old single-asset sequential fallback to bare assets that do not belong to any group. + +--- + +## 14. Acceptance Criteria + +The implementation is accepted only if all are true: + +1. No caption may consume bare assets that belong to a merged figure group +2. No two captions may consume the same figure group +3. A page with caption surplus may match backward without requiring caption-at-page-top heuristics +4. A page with group surplus may match forward without requiring ad hoc bundle exceptions +5. 2HEUD5P9 Figure 4 matches page 12 visual groups; Figure 5 keeps page 13 visual groups +6. Orphan count must not increase because a same-page wrong match blocked a valid cross-page match +7. A conflicting two-caption / one-group case resolves to ambiguous/held, not silent duplicate ownership +8. Legacy bare-asset fallback may not consume any asset already owned by a group +9. Stage 1 implementation preserves existing same-page one-to-one figure behavior outside reserved pages + +--- + +## 15. Implementation Notes + +This design intentionally avoids a heavy page classifier. + +The page ledger is the minimum viable abstraction because it answers the actual question the matcher needs: + +```text +Does this page have too many captions or too many figures? +``` + +That signal is enough to drive natural cross-page settlement without baking more same-page geometric patches into the pipeline. + +This design is intentionally **not yet implementation-ready** until the reservation and residual-ledger rules above are respected. A naive "same-page first, cross-page later" implementation will recreate the existing 2HEUD5P9 ownership bug. diff --git a/docs/superpowers/specs/2026-06-22-local-pairing-and-fallback-governance-design.md b/docs/superpowers/specs/2026-06-22-local-pairing-and-fallback-governance-design.md new file mode 100644 index 00000000..96f256b9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-local-pairing-and-fallback-governance-design.md @@ -0,0 +1,688 @@ +# Local Pairing And Fallback Governance Design + +> **Date:** 2026-06-22 +> **Status:** Proposed +> **Scope:** Define a unified model for how semantic figure groups, local caption-group pairing modes, page-level ledger checks, ownership state, and late fallback paths should interact. This spec exists to prevent one-off fixes for sidecar, caption-above, cross-page, preproof, legacy fallback, and table/figure conflicts from drifting into contradictory heuristics. + +--- + +## 1. Why This Spec Exists + +The current figure pipeline has already been improved in two important ways: + +1. grouping truth is now caption-independent via semantic grouping +2. some late fallback theft has already been reduced via grouped-asset guards + +But the pipeline still lacks one unified governance model for how all of these should relate: + +1. semantic grouping truth +2. local same-page pairing +3. caption-above vs caption-below vs sidecar interpretation +4. page-level reserve / cross-page settlement +5. late fallback paths (`sidecar`, `legend_bundle`, `group_sequential`, `sequential`, `sequence_match`) +6. figure/table asset ownership boundaries + +Without a unifying model, the system will continue to accrete one heuristic per failure mode. +That creates three kinds of risk: + +```text +1. contradictory ownership rules +2. fallback paths that steal already-owned assets +3. journal-specific layout fixes that regress other layouts +``` + +This spec exists to define the full pipeline contract before more localized fixes are added. + +--- + +## 2. Current Pipeline Reality + +As of the current code in `paperforge/worker/ocr_figures.py`, the figure pipeline is effectively: + +```text +collect legends/assets +-> build semantic candidate groups +-> attach caption-band assist metadata +-> build ledger / residual ledger +-> reserve some legends/groups for cross-page settlement +-> same-page matching using `_score_legend_to_group()` +-> cross-page settlement for reserved objects +-> sidecar fallback +-> preproof legend_bundle fallback +-> group-aware sequential fallback +-> old sequential fallback for ungrouped assets +-> sequence_match promotion +``` + +This is already a layered system. +The remaining problem is not that stages are missing. +The problem is that the stages still do not share one coherent ownership policy. + +### Dependency note + +This governance spec assumes or requires two upstream foundations: + +1. caption-independent semantic grouping truth +2. group-first ledger / reservation / cross-page matching design + +If either foundation is absent in a branch, this spec should be read as the target governance model rather than as a statement that the implementation has already achieved it. + +--- + +## 3. Core Principle + +The pipeline must distinguish three different kinds of truth. + +### 3.1 Semantic truth + +```text +Which visual assets belong to the same figure group? +``` + +This is defined by visual grouping only. +It must not be pre-assigned by caption ownership. + +### 3.2 Local pairing truth + +```text +How does a caption relate to a nearby semantic group on this page? +``` + +This is not group formation. +It is local interpretation of an already-existing caption-group neighborhood. + +### 3.3 Page-level / cross-page settlement truth + +```text +If some captions or groups remain locally unresolved, +should ownership stay same-page, look backward, or look forward? +``` + +This is not local geometry alone. +It uses page-level consistency and reserve logic. + +--- + +## 4. The Three Local Pairing Modes + +Local caption/group pairing must no longer be treated as one default geometry with one-off exceptions. + +The pipeline should recognize three primary local pairing modes. + +### 4.1 `caption_below` + +The caption sits below its semantic group. + +Typical signals: + +1. group bbox is above caption bbox +2. strong horizontal overlap +3. vertical distance is small and uninterrupted +4. no strong text barrier between the group and caption + +This is the default local mode, but it is not universally correct. + +### 4.2 `caption_above` + +The caption sits above its semantic group. + +Typical signals: + +1. caption bbox is above group bbox +2. strong horizontal overlap +3. vertical distance is small and uninterrupted +4. repeated caption-above pattern appears on the page or in the local neighborhood + +This must be treated as a valid local mode, not a damaged `caption_below` case. + +### 4.3 `caption_sidecar` + +The caption sits beside its semantic group, usually in the same row or local horizontal band. + +Typical signals: + +1. strong y-overlap / row alignment between caption and group +2. horizontal adjacency is stronger than vertical adjacency +3. the caption is narrow relative to the page +4. the caption-group pairing is monotonic with neighboring caption/group pairs + +Important: + +```text +sidecar is not defined by "two narrow captions on a page" +``` + +Sidecar may occur with: + +1. one caption + one figure +2. multiple captions + multiple figures +3. mixed pages where one caption/group pair is sidecar while others are below/above + +Therefore sidecar must be modeled as a **local pairing mode**, not a page type. + +--- + +## 5. Local Pairing Produces Hypotheses, Not Ownership + +This is the most important governance rule. + +`caption_below`, `caption_above`, and `caption_sidecar` are **local pairing hypotheses**. +They are not accepted ownership yet. + +Each hypothesis may carry fields such as: + +```python +{ + "legend_block_id": str, + "group_id": str, + "mode": "caption_below" | "caption_above" | "caption_sidecar", + "local_score": float, + "evidence": list[str], + "conflicts": list[str], + "would_consume_asset_ids": list[tuple[int, str]], +} +``` + +Hard rule: + +```text +local hypothesis evaluation must not update used_group_ids or used_asset_page_ids +``` + +Ownership is committed only after: + +1. page ledger / residual ledger has run +2. reservation has excluded cross-page-debt candidates on imbalanced pages +3. local/page consistency validation accepts the hypothesis + +This is what prevents a plausible same-page explanation from stealing a group that should remain available for cross-page settlement. + +--- + +## 6. Pairing Mode Is Local, Not Page-Wide + +The system must not assume that a whole page, or a whole paper, uses exactly one caption arrangement style. + +The following mixtures are valid and must be supported: + +1. sidecar + standard caption-below on the same page +2. caption-above + caption-below in the same paper +3. one cross-page figure plus one same-page figure on the same spread + +Therefore: + +```text +pairing mode is a local caption-group hypothesis, +not a paper-wide or page-wide hard classification +``` + +This is a critical design constraint. + +--- + +## 7. Two-Level Decision Model + +Local pairing must be decided in two layers. + +### 7.1 Layer A: Local pairing hypotheses + +For each caption and nearby semantic-group neighborhood, the pipeline may form candidate interpretations: + +1. `caption_below` +2. `caption_above` +3. `caption_sidecar` + +These are hypotheses only. +They are not yet accepted truth. + +### 7.2 Layer B: Local/page consistency validation + +Those hypotheses must then be validated against neighborhood or page consistency. + +Examples of validating signals: + +1. monotonic ordering across multiple captions/groups +2. no crossing ownership between adjacent local pairs +3. page-level count alignment after hypothetical local assignments +4. no obvious head/tail shift (first legend unmatched, last group unmatched) +5. repeated local pattern across 2+ figures on the same page + +Only after this validation may the system accept `above`, `below`, or `sidecar` for that local region. + +--- + +## 8. Caption Independence Does Not Mean Caption Blindness + +The semantic grouping spec already established that caption/text may act as **neutral separators**. +That principle remains in force here. + +### Allowed uses of caption/text blocks + +Caption/text blocks may be used to answer: + +```text +Do these two asset regions remain visually continuous, +or are they physically interrupted by intervening text? +``` + +### Forbidden uses during semantic grouping + +Caption identity may not be used to answer: + +```text +Which caption already owns this asset before groups exist? +``` + +This distinction prevents the old caption-band contamination from reappearing under a different name. + +--- + +## 9. Sidecar Governance + +Sidecar is the highest-risk local mode because the wrong trigger will re-partition page assets and can steal ownership. + +### 9.1 What sidecar is + +Sidecar means: + +```text +the local caption-group relationship is primarily horizontal / row-wise, +not vertical +``` + +### 9.2 What sidecar is not + +Sidecar is not: + +1. "a page with two narrow captions" +2. "any page where caption width is small" +3. "any multi-column figure page" + +### 9.3 Sidecar qualification requirements + +A local region may enter sidecar handling only if all are true: + +1. the caption is narrow enough to plausibly be sidecar text +2. the caption and candidate group share strong row alignment / y overlap +3. monotonic pairing exists across neighboring local caption/group pairs if more than one is present +4. without local sidecar interpretation, same-page ownership has a high risk of assigning adjacent semantic groups to the wrong captions or forcing an overly coarse page-wide fallback interpretation +5. no stronger ordinary `caption_below` / `caption_above` interpretation explains the local geometry cleanly + +### 9.4 Sidecar trigger must be ownership-safe + +Even when sidecar mode is accepted, it may only consume: + +```text +still-unowned assets or still-unowned semantic group members +``` + +It may not repartition the entire page asset field after earlier ownership has already been established. + +This is a required contract change from the current behavior. + +--- + +## 10. Caption-Above Governance + +Caption-above should not be detected by one brittle rule. + +The safest mechanism is: + +1. allow local `caption_above` hypotheses +2. accept them only after local/page validation confirms that ordinary below-mode interpretation leaves a systematic head/tail mismatch + +### Strong signals for caption-above mode + +1. caption above group with strong x overlap +2. no local group below the caption in below-mode without contradiction +3. page-level pairing under below-mode produces systematic mismatch, such as: + - first legend unmatched + - last figure group unmatched + - a one-position shift in local assignments +4. repeated caption-above arrangement appears more than once in the page neighborhood + +This is a post-pairing validation problem, not merely OCR text classification. + +--- + +## 11. Reservation And Cross-Page Settlement Order + +Cross-page settlement remains secondary to semantic grouping and local pairing hypothesis generation. +It is **not** secondary to greedy same-page ownership commit. + +Correct order: + +```text +semantic grouping +-> local pairing hypotheses +-> page / residual ledger +-> reservation +-> validated same-page commit among non-reserved hypotheses +-> cross-page settlement for reserved / residual objects +-> governed fallback +``` + +Not: + +```text +same-page ownership commit +-> residual +-> cross-page settlement +``` + +Rules: + +1. local pairing modes may be evaluated before cross-page settlement +2. they may not be committed before reservation on imbalanced pages +3. reservation may temporarily withhold otherwise plausible same-page hypotheses from ownership commit +4. cross-page settlement operates only after non-reserved same-page hypotheses have been validated and committed + +Authority guardrail: + +```text +reservation state has higher authority than same-page commit on imbalanced pages +``` + +This preserves the earlier Figure 4 / Figure 5 fix direction while fitting it into a broader local-pairing model. + +--- + +## 12. Ownership State Machine + +Ownership must be explicit, not inferred ad hoc by each fallback. + +### 12.1 Group-level states + +```text +unowned +reserved +matched +ambiguous +held +``` + +### 12.2 Asset-level states + +```text +unowned +owned_by_figure +owned_by_table +reserved_by_group +blocked +``` + +`blocked` must not be a generic catch-all. + +It means the asset is intentionally unavailable for figure fallback because it is, for example: + +1. non-body media +2. table-owned +3. conflict-held +4. excluded by a stronger structural rule + +Every fallback must check both: + +1. the candidate group is not already matched or reserved by a stronger layer +2. every `(page, block_id)` it wants to consume is still unowned, or explicitly allowed by the current group owner contract + +This is the shared ownership registry model all fallback paths must obey. + +Reserved-state failure guardrail: + +```text +a reserved object that fails cross-page settlement must transition to ambiguous or held, +or return to unowned only through an explicit audited release step. +it must not silently re-enter greedy same-page or legacy fallback. +``` + +--- + +## 13. Fallback Governance Hierarchy + +Fallbacks must be treated as a governed family, not as unrelated heuristics. + +### 13.1 Order of authority + +From strongest to weakest: + +1. semantic grouping truth +2. reservation state on imbalanced pages +3. validated same-page local pairing commit among non-reserved objects +4. reserved cross-page settlement +5. governed late fallback +6. audit-only / promotion-only heuristics + +### 13.2 Family members + +Current late-stage family: + +1. sidecar fallback +2. preproof `legend_bundle` fallback +3. group-aware sequential fallback +4. old sequential fallback +5. `sequence_match` promotion + +### 13.3 Shared contract + +Every late fallback path must obey: + +1. consume only still-unowned assets/groups +2. never override already-owned semantic groups +3. emit a full matched-figure contract if it produces a match +4. otherwise remain in ambiguous/held state + +Fallback governance should therefore prefer **hard ownership guards** before any new scoring layer is introduced. + +### 13.4 Fallback permission table + +| Fallback | May consume | Must not do | +|----------|-------------|-------------| +| `sidecar` | local still-unowned semantic groups / group members | repartition the whole page after ownership exists; steal matched groups | +| `legend_bundle` | caption-only page followed by continuous unowned asset/group pages | jump across strong body/table/reference interruptions | +| `group_sequential` | unowned semantic groups, including `single_asset` groups | split multi-asset groups or override stronger local ownership | +| `old sequential` | bare assets not belonging to any semantic group, or an explicitly allowed single-asset compatibility path | consume a multi-asset semantic group member | +| `sequence_match` | only promote an already-formed pairing whose asset/page identity is already known | invent asset ownership from caption order alone; emit half-formed matched figures | + +--- + +## 14. `sequence_match` Must Be A Real Contract Or Not A Match + +`sequence_match` is not allowed to produce half-formed matched figures. + +If promoted entries enter `matched_figures`, they must carry the same core contract as other matches: + +```python +{ + "page": int, + "legend_page": int, + "asset_pages": list[int], + "matched_assets": list[dict], + "asset_block_ids": list[str], + "settlement_type": str, +} +``` + +If that cannot be supplied, the figure must remain `ambiguous` or `held` instead of being promoted to a fake match. + +Additional prohibition: + +```text +sequence_match may not invent asset ownership from caption order alone. +It may only promote an already-formed legend-group pairing whose assets and pages are already known. +``` + +--- + +## 15. Table / Figure Ownership Boundary + +The whole-pipeline model must also account for image-like tables and table-like figures. + +Hard rule: + +```text +one asset block -> at most one semantic owner family +``` + +Meaning: + +1. a block cannot simultaneously become a matched figure asset and a matched table asset +2. dedup / ownership guards at `(page, block_id)` level are required across figure and table paths + +Recommended shared surfaces: + +```text +global_owned_asset_ids +figure_owned_asset_ids +table_owned_asset_ids +``` + +Conflict rule: + +```text +if asset claimed by both figure and table, +do not silently duplicate; +emit ownership_conflict with candidate identifiers +``` + +This is especially important on multi-column mixed-layout pages. + +--- + +## 16. Canonical Layout Families And Their Meaning + +The following papers define the stress cases the unified model must explain. + +### 16.1 `3FDT9652` + +Meaning: + +1. three-column or quasi-three-column pages +2. multiple neighboring figures that must remain separate +3. table/figure mixed pages + +Risk if model is wrong: + +```text +semantic grouping over-merges adjacent figures, +or table/figure ownership collides +``` + +Test target: + +```text +synthetic minimal fixture first, +real-paper observational regression second +``` + +### 16.2 `6FGDBFQN` + +Meaning: + +1. true sidecar-heavy local pairing +2. caption beside figure, not below +3. dense page with multiple local figure/caption regions + +Risk if model is wrong: + +```text +sidecar fallback steals entire page assets, +or sidecar trigger is too weak and misfires on non-sidecar pages +``` + +Test target: + +```text +synthetic local row-pairing fixture first, +real-paper observational regression second +``` + +### 16.3 `8VB9ZVQG` + +Meaning: + +1. large composite figure with loose panel spacing +2. cross-page caption on next page + +Risk if model is wrong: + +```text +semantic grouping over-splits one real figure, +or next-page caption pairing becomes fragile +``` + +Test target: + +```text +synthetic large-composite + next-page-caption fixture first, +real-paper observational regression second +``` + +### 16.4 `24YKLTHQ` + +Meaning: + +1. caption-above convention +2. noisy `Source: own` attribution lines +3. figure/table role instability + +Risk if model is wrong: + +```text +caption-below bias breaks ownership, +or attribution/source lines pollute legend truth +``` + +Test target: + +```text +synthetic caption-above + attribution fixture first, +real-paper observational regression second +``` + +--- + +## 17. Acceptance Criteria + +This holistic model is acceptable only if all are true: + +1. semantic groups remain caption-independent +2. sidecar, above, and below are treated as local pairing modes, not as page-wide hard classes +3. a page may contain mixed local pairing modes without forcing one global interpretation +4. local pairing may be evaluated early, but same-page ownership is not committed before reservation on imbalanced pages +5. late fallbacks consume only still-unowned objects +6. `sequence_match` either emits a full matched-figure contract or does not promote +7. figure/table ownership is one-owner-only at `(page, block_id)` level +8. `3FDT9652` does not over-merge adjacent figures +9. `6FGDBFQN` true sidecar layouts can be locally interpreted without page-wide theft +10. `8VB9ZVQG` loose composite figures are not over-split +11. `24YKLTHQ` caption-above layouts can be recovered through local/page validation rather than below-only bias + +--- + +## 18. Implementation Consequence + +No more single-issue fixes should be introduced without first locating them in this model: + +1. Is it a semantic grouping issue? +2. Is it a local pairing hypothesis issue? +3. Is it a reservation / cross-page settlement issue? +4. Is it a late fallback governance issue? +5. Is it an output contract issue? + +If a change cannot be placed into one of those layers, it is probably another isolated patch and should be reconsidered. + +--- + +## 19. Summary + +The system should be thought of as: + +```text +group first from vision, +generate local pairing hypotheses, +reserve before greedy same-page ownership on imbalanced pages, +commit validated same-page ownership, +then fallback only under ownership-safe governance +``` + +That is the whole-pipeline discipline this spec establishes. diff --git a/paperforge/worker/ocr.py b/paperforge/worker/ocr.py index 9179a0f9..905bd106 100644 --- a/paperforge/worker/ocr.py +++ b/paperforge/worker/ocr.py @@ -1427,6 +1427,8 @@ def caption_group_assignments(blocks: list[dict]) -> tuple[dict[int, list[dict]] best_caption = None best_distance = None for caption in figure_captions: + if block.get("page") != caption.get("page"): + continue cb = caption.get("block_bbox", [0, 0, 0, 0]) if bbox[1] < cb[1]: horizontal_overlap = _bbox_horizontal_overlap(bbox, cb) @@ -1878,6 +1880,9 @@ def postprocess_ocr_result(vault: Path, key: str, all_results: list[dict]) -> tu # --- Phase 2: table inventory --- table_inventory = build_table_inventory(structured) + from paperforge.worker.ocr_figures import attach_ownership_conflicts + + attach_ownership_conflicts(figure_inventory, table_inventory) write_back_table_roles(table_inventory, structured) write_table_inventory( artifacts.blocks_structured.parent / "table_inventory.json", diff --git a/paperforge/worker/ocr_figure_reader.py b/paperforge/worker/ocr_figure_reader.py index 1348f30b..c69f24a1 100644 --- a/paperforge/worker/ocr_figure_reader.py +++ b/paperforge/worker/ocr_figure_reader.py @@ -148,6 +148,7 @@ def _normalize_bucket( "height_ratio": float(source_item.get("height_ratio", 0.0)), "media_block_count": int(source_item.get("media_block_count", len(_asset_ids_from_item(source_item)))), "page": source_item.get("page", block.get("page")), + "legend_page": source_item.get("legend_page", source_page), "zone": (legend_data.get("zone") or source_item.get("zone") or block.get("zone")), "style_family": ( legend_data.get("style_family") or source_item.get("style_family") or block.get("style_family") @@ -178,6 +179,10 @@ def _build_bucket( ) +def _caption_consumption_page(item: dict) -> int | str | None: + return item.get("legend_page", item.get("page")) + + _READER_STATUS_MAP = { "matched_figures": "EXACT_MATCH", "held_figures": "HOLD", @@ -281,7 +286,7 @@ def _materialize_reader_figure( "rendered_as_representative": True, } ], - "consumed_caption_block_ids": [{"page": normalized_item.get("page"), "block_id": legend_block_id}] + "consumed_caption_block_ids": [{"page": _caption_consumption_page(normalized_item), "block_id": legend_block_id}] if legend_block_id is not None else [], "consumed_asset_block_ids": [{"page": normalized_item.get("page"), "block_id": aid} for aid in asset_ids], @@ -312,7 +317,7 @@ def _materialize_reader_figure( "rendered_as_representative": False, } ], - "consumed_caption_block_ids": [{"page": normalized_item.get("page"), "block_id": legend_block_id}] + "consumed_caption_block_ids": [{"page": _caption_consumption_page(normalized_item), "block_id": legend_block_id}] if legend_block_id is not None else [], "consumed_asset_block_ids": [], @@ -337,7 +342,7 @@ def _materialize_reader_figure( status="legend_only_group", ) ], - "consumed_caption_block_ids": [{"page": normalized_item.get("page"), "block_id": legend_block_id}] + "consumed_caption_block_ids": [{"page": _caption_consumption_page(normalized_item), "block_id": legend_block_id}] if legend_block_id is not None else [], "consumed_asset_block_ids": [], @@ -364,7 +369,7 @@ def _materialize_reader_figure( status="legend_only_group", ) ], - "consumed_caption_block_ids": [{"page": normalized_item.get("page"), "block_id": legend_block_id}] + "consumed_caption_block_ids": [{"page": _caption_consumption_page(normalized_item), "block_id": legend_block_id}] if legend_block_id is not None else [], "consumed_asset_block_ids": [], diff --git a/paperforge/worker/ocr_figures.py b/paperforge/worker/ocr_figures.py index f44b4370..50bd68d0 100644 --- a/paperforge/worker/ocr_figures.py +++ b/paperforge/worker/ocr_figures.py @@ -392,8 +392,13 @@ def _has_text_separator(a: dict, b: dict, page_blocks: list[dict]) -> bool: if block.get("page") != a_page: continue role = block.get("role", "") - if role not in ("body_paragraph", "section_heading", "subsection_heading", - "backmatter_heading", "backmatter_body"): + if role not in ( + "body_paragraph", + "section_heading", + "subsection_heading", + "backmatter_heading", + "backmatter_body", + ): continue txt = str(block.get("text", "") or "").strip() if not txt or len(txt) < 10: @@ -480,10 +485,13 @@ 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("raw_label", "") == "table" and " list[dict]: result.append(b) elif role == "media_asset": rl = str(b.get("raw_label", "") or "") - if rl in {"image", "chart", "figure"}: - result.append(b) - elif not rl.strip(): - result.append(b) - elif rl == "table" and " set[frozenset[str]]: + return {frozenset(str(bid) for bid in g.get("asset_block_ids", [])) for g in groups} + + +def _estimate_page_height(page_blocks: list[dict]) -> float: + explicit = [float(b["page_height"]) for b in page_blocks if b.get("page_height")] + if explicit: + return max(explicit) + bottoms = [float((b.get("bbox") or [0, 0, 0, 0])[3]) for b in page_blocks if len(b.get("bbox") or []) >= 4] + return max(bottoms, default=1600.0) + + +def _cluster_semantic_page_assets( + page_assets: list[dict], + page_blocks: list[dict], + page_width: float, + page_height: float, +) -> list[list[dict]]: + if not page_assets: + return [] + if len(page_assets) == 1: + return [list(page_assets)] + + # Semantic grouping stays stricter than the legacy caption-aware path so + # ordinary side-by-side same-page figures do not collapse into one group. + h_threshold = max(page_width * 0.03, 40.0) + v_threshold = max(min(page_width, page_height) * 0.08, 40.0) + 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: + continue + if v_gap > v_threshold: + continue + if _has_text_separator(a, b, page_blocks): + continue + + union(i, j) + + 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()) + + +def _build_semantic_figure_groups_from_assets( assets: list[dict], all_blocks: list[dict], - legends: list[dict], + *, page_width: float = 1200, ) -> list[dict]: + # semantic groups are caption-independent; caption/text may only act as neutral separators + # and caption count may not change topology. media = _filter_figure_assets(assets) groups: list[dict] = [] next_id = 1 - def _estimate_page_height(page_blocks: list[dict]) -> float: - explicit = [float(b["page_height"]) for b in page_blocks if b.get("page_height")] - if explicit: - return max(explicit) - bottoms = [ - float((b.get("bbox") or [0, 0, 0, 0])[3]) - for b in page_blocks if len(b.get("bbox") or []) >= 4 - ] - return max(bottoms, default=1600.0) - by_page: dict[int, list[dict]] = {} for block in media: by_page.setdefault(int(block.get("page", 0) or 0), []).append(block) @@ -590,40 +659,19 @@ def _build_candidate_figure_groups_from_assets( for page, page_media in by_page.items(): page_blocks = [b for b in all_blocks if int(b.get("page", 0) or 0) == page] page_height = _estimate_page_height(page_blocks) - - page_legends = [l for l in legends if l.get("page") == page] - n_legends = len(page_legends) - - # Multi-legend: partition by caption bands first - if n_legends >= 2: - band_map = _partition_assets_by_caption_bands(page_legends, page_media, page_height) - partitions: list[tuple[str | None, list[dict]]] = [ - (band_id, list(assets)) for band_id, assets in band_map.items() if assets - ] - assigned_ids = {id(a) for _, p in partitions for a in p} - free = [a for a in page_media if id(a) not in assigned_ids] - if free: - partitions.append((None, free)) - else: - partitions = [(None, list(page_media))] - - # Cluster each partition + clusters = _cluster_semantic_page_assets(page_media, page_blocks, page_width, page_height) page_groups: list[dict] = [] - for band_id, partition in partitions: - if not partition: - continue - clusters = _cluster_page_assets(partition, page_blocks, n_legends, page_width, page_height) - for cluster in clusters: - gt = "distance_cluster" if len(cluster) >= 2 else "single_asset" - entry = _candidate_group_entry( - f"group_{next_id:04d}", page, cluster, gt, - ["same_page", "distance_clustered" if gt == "distance_cluster" else "single_asset"], - ) - entry["caption_band_id"] = band_id - entry["page_legend_count"] = n_legends - entry["safe_auto_match"] = False - page_groups.append(entry) - next_id += 1 + for cluster in clusters: + gt = "distance_cluster" if len(cluster) >= 2 else "single_asset" + entry = _candidate_group_entry( + f"group_{next_id:04d}", + page, + cluster, + gt, + ["same_page", "distance_clustered" if gt == "distance_cluster" else "single_asset"], + ) + page_groups.append(entry) + next_id += 1 page_group_count = len(page_groups) page_distance_cluster_count = sum(1 for g in page_groups if g["group_type"] == "distance_cluster") @@ -631,8 +679,7 @@ def _build_candidate_figure_groups_from_assets( g["page_group_count"] = page_group_count g["page_distance_cluster_count"] = page_distance_cluster_count g["safe_auto_match"] = ( - n_legends == 1 - and page_group_count == 1 + page_group_count == 1 and g["group_type"] == "distance_cluster" and len(g.get("media_blocks", [])) >= 2 ) @@ -642,6 +689,91 @@ def _build_candidate_figure_groups_from_assets( return groups +def _build_caption_band_group_assist( + semantic_groups: list[dict], + page_legends: list[dict], + page_media: list[dict], + page_height: float, +) -> dict[str, dict]: + if len(page_legends) < 2 or not semantic_groups or not page_media: + return {} + + band_map = _partition_assets_by_caption_bands(page_legends, page_media, page_height) + assist: dict[str, dict] = {} + for group in semantic_groups: + asset_ids = {str(bid) for bid in group.get("asset_block_ids", []) if bid is not None} + if not asset_ids: + assist[str(group.get("group_id", ""))] = { + "caption_band_ids": [], + "best_caption_band_id": None, + "overlap_ratio": 0.0, + "band_overlap": {}, + "evidence": [], + } + continue + + overlap_by_band: dict[str, float] = {} + for band_id, band_assets in band_map.items(): + band_asset_ids = {str(a.get("block_id", "")) for a in band_assets if a.get("block_id") is not None} + if not band_asset_ids: + continue + overlap = len(asset_ids & band_asset_ids) / max(1, len(asset_ids)) + if overlap > 0: + overlap_by_band[str(band_id)] = overlap + + best_caption_band_id = None + overlap_ratio = 0.0 + if overlap_by_band: + best_caption_band_id, overlap_ratio = max(overlap_by_band.items(), key=lambda item: item[1]) + + assist[str(group.get("group_id", ""))] = { + "caption_band_ids": sorted(overlap_by_band), + "best_caption_band_id": best_caption_band_id, + "overlap_ratio": overlap_ratio, + "band_overlap": overlap_by_band, + "evidence": ["caption_band_assist"] if overlap_by_band else [], + } + + return assist + + +def _build_candidate_figure_groups_from_assets( + assets: list[dict], + all_blocks: list[dict], + legends: list[dict], + page_width: float = 1200, +) -> list[dict]: + groups = _build_semantic_figure_groups_from_assets(assets, all_blocks, page_width=page_width) + groups_by_page: dict[int, list[dict]] = {} + media_by_page: dict[int, list[dict]] = {} + for group in groups: + groups_by_page.setdefault(int(group.get("page", 0) or 0), []).append(group) + for block in _filter_figure_assets(assets): + media_by_page.setdefault(int(block.get("page", 0) or 0), []).append(block) + + for page, page_groups in groups_by_page.items(): + page_blocks = [b for b in all_blocks if int(b.get("page", 0) or 0) == page] + page_height = _estimate_page_height(page_blocks) + page_legends = [l for l in legends if l.get("page") == page] + band_assist = _build_caption_band_group_assist( + page_groups, + page_legends, + media_by_page.get(page, []), + page_height, + ) + for group in page_groups: + group["page_legend_count"] = len(page_legends) + group["assist"] = band_assist.get(str(group.get("group_id", "")), { + "caption_band_ids": [], + "best_caption_band_id": None, + "overlap_ratio": 0.0, + "band_overlap": {}, + "evidence": [], + }) + + return groups + + def _score_legend_to_group( legend: dict, group: dict, @@ -658,8 +790,11 @@ def _score_legend_to_group( if gt == "distance_cluster": num_assets = len(group.get("media_blocks", [])) if group.get("safe_auto_match") and num_assets >= 2: - return {"score": 0.85, "decision": "matched", - "evidence": ["same_page", "distance_clustered", "safe_auto_match"]} + return { + "score": 0.85, + "decision": "matched", + "evidence": ["same_page", "distance_clustered", "safe_auto_match"], + } cluster_bbox = group.get("cluster_bbox", [0, 0, 0, 0]) match_score = score_figure_match( @@ -725,11 +860,252 @@ def _score_legend_to_group( return match_score +def _asset_page_id(page: Any, block_id: Any) -> tuple[int, str] | None: + if block_id in (None, ""): + return None + return (int(page or 0), str(block_id)) + + +def _make_local_pairing_hypothesis( + legend: dict, + group: dict, + *, + mode: str, + local_score: float, + evidence: list[str] | None = None, + conflicts: list[str] | None = None, +) -> dict: + media_blocks = group.get("media_blocks", []) + if media_blocks: + asset_ids = [ + aid + for block in media_blocks + if (aid := _asset_page_id(block.get("page", group.get("page", 0)), block.get("block_id", ""))) is not None + ] + else: + group_page = group.get("page", 0) + asset_ids = [ + aid for block_id in group.get("asset_block_ids", []) if (aid := _asset_page_id(group_page, block_id)) is not None + ] + return { + "legend_block_id": str(legend.get("block_id", "")), + "group_id": str(group.get("group_id", "")), + "mode": str(mode), + "local_score": float(local_score), + "evidence": list(evidence or []), + "conflicts": list(conflicts or []), + "would_consume_asset_ids": asset_ids, + } + + +def _infer_local_pairing_mode(legend: dict, group: dict, *, page_width: float = 1200) -> str: + legend_bbox = legend.get("bbox") or legend.get("block_bbox") or [0, 0, 0, 0] + group_bbox = group.get("cluster_bbox") or [0, 0, 0, 0] + if len(legend_bbox) >= 4 and len(group_bbox) >= 4: + legend_width = float(legend_bbox[2] - legend_bbox[0]) + legend_height = max(1.0, float(legend_bbox[3] - legend_bbox[1])) + group_height = max(1.0, float(group_bbox[3] - group_bbox[1])) + y_overlap = max(0.0, min(legend_bbox[3], group_bbox[3]) - max(legend_bbox[1], group_bbox[1])) + x_gap = min(abs(group_bbox[0] - legend_bbox[2]), abs(legend_bbox[0] - group_bbox[2])) + if ( + y_overlap >= min(legend_height, group_height) * 0.5 + and x_gap <= float(page_width) * 0.25 + and legend_width < float(page_width) * 0.6 + ): + return "caption_sidecar" + vertical_side = _asset_vertical_side(legend, group) + if vertical_side == "below": + return "caption_above" + if vertical_side == "above": + return "caption_below" + return "caption_below" + + +def _mark_hypothesis_conflict(hypothesis: dict, conflict: str) -> None: + if not conflict: + return + conflicts = hypothesis.setdefault("conflicts", []) + if conflict not in conflicts: + conflicts.append(conflict) + + +class FigureOwnershipRegistry: + def __init__( + self, + *, + used_group_ids: set[str] | None = None, + used_asset_page_ids: set[tuple[int, str]] | None = None, + ) -> None: + self.used_group_ids = used_group_ids if used_group_ids is not None else set() + self.used_asset_page_ids = used_asset_page_ids if used_asset_page_ids is not None else set() + self.group_states: dict[str, dict[str, str]] = {} + self.asset_states: dict[tuple[int, str], dict[str, str]] = {} + + def reserve_group(self, group_id: str, *, reason: str) -> None: + self._require_reason(reason) + self.group_states[str(group_id)] = {"state": "reserved", "reason": reason} + + def transition_reserved_to_held(self, group_id: str, *, reason: str) -> None: + self._require_reason(reason) + self.group_states[str(group_id)] = {"state": "held", "reason": reason} + + def match_group(self, group: dict, *, owner_id: str, owner_family: str) -> None: + group_id = str(group.get("group_id", "")) + if group_id: + self.group_states[group_id] = { + "state": "matched", + "owner_id": str(owner_id), + "owner_family": str(owner_family), + } + self.used_group_ids.add(group_id) + self.mark_assets_owned(self._group_asset_page_ids(group), owner_id=owner_id, owner_family=owner_family) + + def mark_assets_owned( + self, + asset_ids: list[tuple[int, str]], + *, + owner_id: str, + owner_family: str, + ) -> None: + normalized_owner_id = str(owner_id) + normalized_owner_family = str(owner_family) + for asset_id in asset_ids: + normalized = _asset_page_id(asset_id[0], asset_id[1]) + if normalized is None: + continue + current = self.asset_states.get(normalized) + if current and current.get("owner_family") != normalized_owner_family: + raise ValueError(f"Asset {normalized[1]} already owned by {current.get('owner_family')}") + self.asset_states[normalized] = { + "state": f"owned_by_{normalized_owner_family}", + "owner_id": normalized_owner_id, + "owner_family": normalized_owner_family, + } + self.used_asset_page_ids.add(normalized) + + def block_asset(self, asset_id: tuple[int, str], *, reason: str) -> None: + self._require_reason(reason) + normalized = _asset_page_id(asset_id[0], asset_id[1]) + if normalized is None: + raise ValueError("blocked asset requires an asset id") + self.asset_states[normalized] = {"state": "blocked", "reason": reason} + + def can_consume_group(self, group: dict) -> bool: + group_id = str(group.get("group_id", "")) + if group_id and group_id in self.used_group_ids: + return False + return self.can_consume_assets(self._group_asset_page_ids(group)) + + def can_consume_assets(self, asset_ids: list[tuple[int, str]]) -> bool: + for asset_id in asset_ids: + normalized = _asset_page_id(asset_id[0], asset_id[1]) + if normalized is None: + continue + if normalized in self.used_asset_page_ids: + return False + return True + + def _group_asset_page_ids(self, group: dict) -> list[tuple[int, str]]: + media_blocks = group.get("media_blocks", []) + if media_blocks: + return [ + normalized + for block in media_blocks + if (normalized := _asset_page_id(block.get("page", group.get("page", 0)), block.get("block_id", ""))) + is not None + ] + group_page = group.get("page", 0) + return [ + normalized + for bid in group.get("asset_block_ids", []) + if (normalized := _asset_page_id(group_page, bid)) is not None + ] + + def _require_reason(self, reason: str) -> None: + if not str(reason).strip(): + raise ValueError("ownership reason is required") + + +def _fallback_eligible_asset_page_ids( + asset_page_ids: list[tuple[int, str]], + *, + used_asset_page_ids: set[tuple[int, str]], + blocked_asset_page_ids: set[tuple[int, str]], + grouped_asset_page_ids: set[tuple[int, str]] | None = None, + allow_grouped: bool = False, +) -> list[tuple[int, str]]: + grouped = grouped_asset_page_ids or set() + eligible: list[tuple[int, str]] = [] + for page, block_id in asset_page_ids: + asset_id = _asset_page_id(page, block_id) + if asset_id is None: + continue + if asset_id in used_asset_page_ids or asset_id in blocked_asset_page_ids: + continue + if not allow_grouped and asset_id in grouped: + continue + eligible.append(asset_id) + return eligible + + +def _fallback_can_consume( + asset_page_ids: list[tuple[int, str]], + *, + used_asset_page_ids: set[tuple[int, str]], + blocked_asset_page_ids: set[tuple[int, str]], + grouped_asset_page_ids: set[tuple[int, str]] | None = None, + allow_grouped: bool = False, +) -> bool: + normalized = [aid for aid in (_asset_page_id(page, block_id) for page, block_id in asset_page_ids) if aid is not None] + return len(_fallback_eligible_asset_page_ids( + normalized, + used_asset_page_ids=used_asset_page_ids, + blocked_asset_page_ids=blocked_asset_page_ids, + grouped_asset_page_ids=grouped_asset_page_ids, + allow_grouped=allow_grouped, + )) == len(normalized) + + +def _fallback_eligible_groups( + groups: list[dict], + *, + used_group_ids: set[str], + used_asset_page_ids: set[tuple[int, str]], + blocked_asset_page_ids: set[tuple[int, str]] | None = None, + grouped_asset_page_ids: set[tuple[int, str]] | None = None, + allow_grouped: bool = True, +) -> list[dict]: + blocked = blocked_asset_page_ids or set() + eligible: list[dict] = [] + for group in groups: + group_id = str(group.get("group_id", "")) + if group_id and group_id in used_group_ids: + continue + asset_ids = [ + aid + for aid in ( + _asset_page_id(group.get("page", 0), bid) + for bid in group.get("asset_block_ids", []) + ) + if aid is not None + ] + if not _fallback_can_consume( + asset_ids, + used_asset_page_ids=used_asset_page_ids, + blocked_asset_page_ids=blocked, + grouped_asset_page_ids=grouped_asset_page_ids, + allow_grouped=allow_grouped, + ): + continue + eligible.append(group) + return eligible + + def _expand_matched_assets_locally( legend: dict, matched_assets: list[dict], assets: list[dict], - used_asset_page_ids: set[tuple], + ownership: FigureOwnershipRegistry, page_captions: list[dict], ) -> list[dict]: if not matched_assets: @@ -755,7 +1131,7 @@ def _expand_matched_assets_locally( for asset in assets: ap = asset.get("page", 0) aid = asset.get("block_id", "") - if not aid or (ap, aid) in used_asset_page_ids: + if not aid or not ownership.can_consume_assets([(ap, aid)]): continue if ap != legend.get("page", 0): continue @@ -778,7 +1154,7 @@ def _expand_matched_assets_locally( if touches_stack and wide_enough_overlap: current.append(asset) - used_asset_page_ids.add((ap, aid)) + ownership.mark_assets_owned([(ap, aid)], owner_id=str(legend.get("block_id", "")), owner_family="figure") changed = True current.sort(key=lambda a: ((a.get("bbox") or [0, 0, 0, 0])[1], (a.get("bbox") or [0, 0, 0, 0])[0])) @@ -924,6 +1300,23 @@ def _same_page_narrow_caption_column(page_captions: list[dict], page_width: floa return ordered +def _caption_row_coupled_assets(caption: dict, assets: list[dict], *, page_width: float = 1200) -> list[dict]: + caption_bbox = caption.get("bbox") or [0, 0, 0, 0] + if len(caption_bbox) < 4: + return [] + caption_height = max(1.0, float(caption_bbox[3] - caption_bbox[1])) + coupled: list[dict] = [] + for asset in assets: + asset_bbox = asset.get("bbox") or [0, 0, 0, 0] + if len(asset_bbox) < 4: + continue + y_overlap = max(0.0, min(caption_bbox[3], asset_bbox[3]) - max(caption_bbox[1], asset_bbox[1])) + x_gap = min(abs(asset_bbox[0] - caption_bbox[2]), abs(caption_bbox[0] - asset_bbox[2])) + if y_overlap >= caption_height * 0.5 and x_gap <= float(page_width) * 0.25: + coupled.append(asset) + return coupled + + def _asset_vertical_side(legend: dict, group: dict) -> str: legend_bbox = legend.get("bbox") or legend.get("block_bbox") or [0, 0, 0, 0] cluster_bbox = group.get("cluster_bbox") or [0, 0, 0, 0] @@ -1025,6 +1418,478 @@ def _partition_assets_by_caption_bands( return result +# --- Cross-page figure matching helpers (Stage 1) --- + + +def _is_strong_numbered_legend( + block: dict, + *, + caption_score: dict | None = None, + anchor_supported: bool | None = None, + caption_text_supported: bool | None = None, +) -> bool: + if _extract_figure_number(str(block.get("text") or "")) is None: + return False + if not _is_formal_legend(block.get("text", ""), block): + return False + if _is_insufficient_legend_evidence(block): + return False + cs = ( + caption_score + if caption_score is not None + else score_figure_caption( + block, + nearby_media=False, + caption_style_match=False, + body_prose_likelihood=_looks_like_inline_figure_mention(block.get("text", ""), block), + ) + ) + if cs.get("score", 0) < 0.4: + return False + if _is_validation_first_legend_candidate(block): + a_s = anchor_supported if anchor_supported is not None else _has_anchor_supported_legend_context(block) + c_s = caption_text_supported if caption_text_supported is not None else _has_strong_explicit_caption_text(block) + if not a_s and not c_s: + return False + return True + + +def _is_structurally_matchable_group(group: dict, *, competing_caption_pages: set[int]) -> bool: + if not group.get("asset_block_ids"): + return False + if group.get("_non_body_media"): + return False + cb = group.get("cluster_bbox") + if not cb or len(cb) < 4: + return False + if group.get("group_type") == "page_assets" and group.get("page") in competing_caption_pages: + return False + return True + + +def _is_unowned_matchable_group( + group: dict, + *, + competing_caption_pages: set[int], + used_group_ids: set[str], + used_asset_page_ids: set[tuple], +) -> bool: + if not _is_structurally_matchable_group(group, competing_caption_pages=competing_caption_pages): + return False + if str(group.get("group_id", "")) in used_group_ids: + return False + g_page = group.get("page", 0) + for bid in group.get("asset_block_ids", []): + if bid and (g_page, bid) in used_asset_page_ids: + return False + return True + + +def _build_page_ledger(legends: list[dict], candidate_groups: list[dict]) -> dict[int, dict]: + ledger: dict[int, dict] = {} + for leg in legends: + p = int(leg.get("page", 0) or 0) + if p not in ledger: + ledger[p] = { + "page": p, + "legend_count": 0, + "numbered_legend_count": 0, + "group_count": 0, + "top_legend_count": 0, + "bottom_legend_count": 0, + "delta": 0, + } + ledger[p]["legend_count"] += 1 + if _extract_figure_number(str(leg.get("text") or "")) is not None: + ledger[p]["numbered_legend_count"] += 1 + for g in candidate_groups: + p = int(g.get("page", 0) or 0) + if p not in ledger: + ledger[p] = { + "page": p, + "legend_count": 0, + "numbered_legend_count": 0, + "group_count": 0, + "top_legend_count": 0, + "bottom_legend_count": 0, + "delta": 0, + } + ledger[p]["group_count"] += 1 + for p in ledger: + ledger[p]["delta"] = ledger[p]["legend_count"] - ledger[p]["group_count"] + return ledger + + +def _build_residual_ledger( + legends: list[dict], + candidate_groups: list[dict], + *, + competing_caption_pages: set[int], +) -> dict[int, dict]: + ledger: dict[int, dict] = {} + for leg in legends: + p = int(leg.get("page", 0) or 0) + if not _is_strong_numbered_legend(leg): + continue + if p not in ledger: + ledger[p] = { + "page": p, + "unmatched_strong_legend_count": 0, + "unmatched_matchable_group_count": 0, + "residual_delta": 0, + } + ledger[p]["unmatched_strong_legend_count"] += 1 + for g in candidate_groups: + p = int(g.get("page", 0) or 0) + if not _is_structurally_matchable_group(g, competing_caption_pages=competing_caption_pages): + continue + if p not in ledger: + ledger[p] = { + "page": p, + "unmatched_strong_legend_count": 0, + "unmatched_matchable_group_count": 0, + "residual_delta": 0, + } + ledger[p]["unmatched_matchable_group_count"] += 1 + for p in ledger: + ledger[p]["residual_delta"] = ( + ledger[p]["unmatched_strong_legend_count"] - ledger[p]["unmatched_matchable_group_count"] + ) + return ledger + + +def _residual_group_surplus(pages: list[int], residual_ledger: dict[int, dict]) -> int: + return sum( + max( + 0, + residual_ledger[p]["unmatched_matchable_group_count"] - residual_ledger[p]["unmatched_strong_legend_count"], + ) + for p in pages + if p in residual_ledger + ) + + +def _residual_legend_surplus(pages: list[int], residual_ledger: dict[int, dict]) -> int: + return sum( + max( + 0, + residual_ledger[p]["unmatched_strong_legend_count"] - residual_ledger[p]["unmatched_matchable_group_count"], + ) + for p in pages + if p in residual_ledger + ) + + +def _grouped_asset_page_ids(candidate_groups: list[dict]) -> set[tuple[int, str]]: + return { + (int(group.get("page", 0) or 0), str(bid)) + for group in candidate_groups + for bid in group.get("asset_block_ids", []) + if bid is not None + } + + +def _recompute_final_unmatched_assets( + assets: list[dict], + used_asset_page_ids: set[tuple], + unresolved_clusters: list[dict], +) -> list[dict]: + unresolved_asset_page_ids: set[tuple[int, str]] = set() + for cluster in unresolved_clusters: + page = int(cluster.get("page", 0) or 0) + for bid in cluster.get("media_block_ids", []): + if bid is not None: + unresolved_asset_page_ids.add((page, str(bid))) + + final_unmatched: list[dict] = [] + for asset in assets: + page = int(asset.get("page", 0) or 0) + bid = asset.get("block_id", "") + if not bid: + final_unmatched.append(asset) + continue + page_bid = (page, str(bid)) + if page_bid in used_asset_page_ids or page_bid in unresolved_asset_page_ids: + continue + final_unmatched.append(asset) + return final_unmatched + + +def _reserve_cross_page_objects( + legends: list[dict], + candidate_groups: list[dict], + residual_ledger: dict[int, dict], + *, + competing_caption_pages: set[int], + sidecar_pages: set[int], + used_group_ids: set[str] | None = None, + used_asset_page_ids: set[tuple] | None = None, +) -> tuple[set[str], set[str]]: + reserved_legend_ids: set[str] = set() + reserved_group_ids: set[str] = set() + + legends_by_page: dict[int, list[dict]] = {} + for leg in legends: + p = int(leg.get("page", 0) or 0) + legends_by_page.setdefault(p, []).append(leg) + + groups_by_page: dict[int, list[dict]] = {} + for g in candidate_groups: + p = int(g.get("page", 0) or 0) + groups_by_page.setdefault(p, []).append(g) + + used_gids = used_group_ids or set() + used_aids = used_asset_page_ids or set() + + for page, entry in residual_ledger.items(): + if page in sidecar_pages: + continue + if entry["residual_delta"] > 0: + k = min(entry["residual_delta"], _residual_group_surplus([page - 1, page - 2], residual_ledger)) + if k <= 0: + continue + page_legends = sorted( + [l for l in legends_by_page.get(page, []) if _is_strong_numbered_legend(l)], + key=lambda l: (l.get("bbox") or [0, 0, 0, 0])[1], + ) + for leg in page_legends[:k]: + reserved_legend_ids.add(str(leg.get("block_id", ""))) + if entry["residual_delta"] < 0: + k = min(-entry["residual_delta"], _residual_legend_surplus([page + 1, page + 2], residual_ledger)) + if k <= 0: + continue + page_groups = sorted( + [ + g + for g in groups_by_page.get(page, []) + if _is_unowned_matchable_group( + g, + competing_caption_pages=competing_caption_pages, + used_group_ids=used_gids, + used_asset_page_ids=used_aids, + ) + ], + key=lambda g: (g.get("cluster_bbox") or [0, 0, 0, 0])[3], + reverse=True, + ) + for grp in page_groups[:k]: + reserved_group_ids.add(str(grp.get("group_id", ""))) + + return reserved_legend_ids, reserved_group_ids + + +INTERRUPTION_ROLES = { + "section_heading", + "subsection_heading", + "sub_subsection_heading", + "table_caption", + "table_html", + "reference_heading", + "reference_item", + "backmatter_heading", + "backmatter_body", +} + + +def _has_strong_interruption(page: int, structured_blocks: list[dict]) -> bool: + body_count = sum( + 1 for b in structured_blocks if int(b.get("page", 0) or 0) == page and b.get("role") == "body_paragraph" + ) + if body_count >= 3: + return True + return any( + int(b.get("page", 0) or 0) == page and b.get("role", "") in INTERRUPTION_ROLES for b in structured_blocks + ) + + +def _settle_cross_page_reserved_objects( + reserved_legend_ids: set[str], + reserved_group_ids: set[str], + legends: list[dict], + candidate_groups: list[dict], + structured_blocks: list[dict], + matched_figures: list[dict], + ambiguous_figures: list[dict], + unmatched_legends: list[dict], + used_group_ids: set[str], + used_asset_page_ids: set[tuple], + ownership: FigureOwnershipRegistry, + *, + residual_ledger: dict[int, dict], + competing_caption_pages: set[int], + page_width: float = 1200, +) -> None: + legends_by_id = {str(leg.get("block_id", "")): leg for leg in legends} + groups_by_id = {str(g.get("group_id", "")): g for g in candidate_groups} + + failed_legend_ids: set[str] = set() + failed_group_ids: set[str] = set() + + # reserved legends look backward + for lid in sorted(reserved_legend_ids): + legend = legends_by_id.get(lid) + if legend is None: + continue + leg_page = int(legend.get("page", 0) or 0) + for pp in (leg_page - 1, leg_page - 2): + if pp < 0: + continue + if pp == leg_page - 2 and _has_strong_interruption(leg_page - 1, structured_blocks): + continue + target_groups = sorted( + [ + g + for g in candidate_groups + if str(g.get("group_id", "")) not in used_group_ids and g.get("page") == pp + ], + key=lambda g: (g.get("cluster_bbox") or [0, 0, 0, 0])[3], + reverse=True, + ) + if not target_groups: + continue + best_group = target_groups[0] + came_from_reserved = str(best_group.get("group_id", "")) in reserved_group_ids + + legend_text = str(legend.get("text") or "") + fn = _extract_figure_number(legend_text) + if fn is None: + continue + ns = _extract_figure_namespace(legend_text) + fig_id = _format_figure_id(ns, fn) + + caption_score = score_figure_caption( + legend, nearby_media=True, caption_style_match=False, body_prose_likelihood=False + ) + + group_page = int(best_group.get("page", 0) or 0) + group_assets = [g for g in best_group.get("media_blocks", [])] + + ownership.match_group(best_group, owner_id=lid, owner_family="figure") + + asset_pages = sorted({int(a.get("page", 0) or 0) for a in group_assets}) + matched_figures.append( + { + "figure_id": fig_id, + "figure_namespace": ns, + "legend_block_id": legend.get("block_id", ""), + "page": group_page, + "legend_page": leg_page, + "asset_pages": asset_pages if asset_pages else [group_page], + "text": legend_text, + "figure_number": fn, + "matched_assets": [ + {"block_id": a.get("block_id", ""), "bbox": a.get("bbox", [0, 0, 0, 0])} for a in group_assets + ], + "asset_block_ids": [str(a.get("block_id", "")) for a in group_assets if a.get("block_id")], + "bridge_block_ids": [], + "group_type": best_group.get("group_type", ""), + "group_evidence": best_group.get("group_evidence", []) + ["cross_page_backward"], + "cluster_bbox": best_group.get("cluster_bbox", [0, 0, 0, 0]), + "confidence": 0.5, + "match_score": {"score": 0.5, "decision": "matched", "evidence": ["cross_page_backward"]}, + "flags": ["cross_page_match"], + "caption_score": caption_score, + "settlement_type": "cross_page_backward", + } + ) + unmatched_legends[:] = [u for u in unmatched_legends if str(u.get("block_id", "")) != lid] + break + else: + failed_legend_ids.add(lid) + + # Build set of already-matched legend IDs to prevent double-matching + _used_legend_ids: set[str] = { + str(m.get("legend_block_id", "")) for m in matched_figures if m.get("legend_block_id") + } + + # reserved groups look forward + for gid in sorted(reserved_group_ids): + if gid in used_group_ids: + continue + group = groups_by_id.get(gid) + if group is None: + continue + group_page = int(group.get("page", 0) or 0) + for np in (group_page + 1, group_page + 2): + if np == group_page + 2 and _has_strong_interruption(group_page + 1, structured_blocks): + continue + candidate_legends = sorted( + [ + l + for l in legends + if str(l.get("block_id", "")) not in _used_legend_ids + and str(l.get("block_id", "")) not in failed_legend_ids + and int(l.get("page", 0) or 0) == np + and _is_strong_numbered_legend(l) + ], + key=lambda l: (l.get("bbox") or [0, 0, 0, 0])[1], + ) + if not candidate_legends: + continue + best_legend = candidate_legends[0] + lid = str(best_legend.get("block_id", "")) + + legend_text = str(best_legend.get("text") or "") + fn = _extract_figure_number(legend_text) + if fn is None: + continue + ns = _extract_figure_namespace(legend_text) + fig_id = _format_figure_id(ns, fn) + + caption_score = score_figure_caption( + best_legend, nearby_media=True, caption_style_match=False, body_prose_likelihood=False + ) + + group_assets = [g for g in group.get("media_blocks", [])] + ownership.match_group(group, owner_id=lid, owner_family="figure") + + asset_pages = sorted({int(a.get("page", 0) or 0) for a in group_assets}) + matched_figures.append( + { + "figure_id": fig_id, + "figure_namespace": ns, + "legend_block_id": best_legend.get("block_id", ""), + "page": group_page, + "legend_page": np, + "asset_pages": asset_pages if asset_pages else [group_page], + "text": legend_text, + "figure_number": fn, + "matched_assets": [ + {"block_id": a.get("block_id", ""), "bbox": a.get("bbox", [0, 0, 0, 0])} for a in group_assets + ], + "asset_block_ids": [str(a.get("block_id", "")) for a in group_assets if a.get("block_id")], + "bridge_block_ids": [], + "group_type": group.get("group_type", ""), + "group_evidence": group.get("group_evidence", []) + ["cross_page_forward"], + "cluster_bbox": group.get("cluster_bbox", [0, 0, 0, 0]), + "confidence": 0.5, + "match_score": {"score": 0.5, "decision": "matched", "evidence": ["cross_page_forward"]}, + "flags": ["cross_page_match"], + "caption_score": caption_score, + "settlement_type": "cross_page_forward", + } + ) + unmatched_legends[:] = [u for u in unmatched_legends if str(u.get("block_id", "")) != lid] + break + else: + failed_group_ids.add(gid) + + # Failed reserved legends + for lid in failed_legend_ids: + legend = legends_by_id.get(lid) + if legend is None: + continue + ambiguous_figures.append( + { + "legend_block_id": lid, + "page": legend.get("page", 0), + "text": legend.get("text", ""), + "figure_number": _extract_figure_number(str(legend.get("text", "") or "")), + "hold_reason": "reserved_cross_page_no_valid_group", + } + ) + + def build_figure_inventory(structured_blocks: list[dict], page_width: float = 1200) -> dict[str, Any]: legends: list[dict] = [] held_figures: list[dict] = [] @@ -1035,7 +1900,10 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 matched_figures: list[dict] = [] unresolved_clusters: list[dict] = [] ambiguous_figures: list[dict] = [] + local_pairing_hypotheses: list[dict] = [] used_group_ids: set[str] = set() + used_asset_page_ids: set[tuple[int, str]] = set() + ownership = FigureOwnershipRegistry(used_group_ids=used_group_ids, used_asset_page_ids=used_asset_page_ids) def _collect_bridge_blocks(page: int) -> list[dict]: bridges: list[dict] = [] @@ -1086,9 +1954,12 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 assets.append(block) elif role == "media_asset": 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 "= 2: + _sidecar_pages.add(sp) + _reserved_legend_ids, _reserved_group_ids = _reserve_cross_page_objects( + ordered_legends, + candidate_groups, + _residual_ledger, + competing_caption_pages=_competing_caption_pages, + sidecar_pages=_sidecar_pages, + ) + # --- end Stage 1 reservation --- + page_caption_index = _formal_figure_caption_blocks(structured_blocks) for legend in ordered_legends: + legend_id = str(legend.get("block_id", "")) + legend_reserved_for_cross_page = legend_id in _reserved_legend_ids legend_page = legend.get("page", 0) legend_text = str(legend.get("text") or "") ns = _extract_figure_namespace(legend_text) @@ -1203,10 +2099,11 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 for gi, group in enumerate(candidate_groups): if group.get("page") != legend_page: continue + group_id = str(group.get("group_id", "")) g_asset_block_ids = set(group.get("asset_block_ids", [])) g_page = group.get("page", 0) g_qual = {(g_page, bid) for bid in g_asset_block_ids} - if g_qual & used_asset_page_ids: + if not ownership.can_consume_assets(list(g_qual)): continue match_score = _score_legend_to_group( legend, @@ -1219,12 +2116,23 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 zone_supported=zone_supported, ) if match_score["decision"] != "rejected": + hypothesis = _make_local_pairing_hypothesis( + legend, + group, + mode=_infer_local_pairing_mode(legend, group, page_width=page_width), + local_score=match_score.get("score", 0.0), + evidence=match_score.get("evidence", []), + ) + if legend_reserved_for_cross_page or group_id in _reserved_group_ids: + _mark_hypothesis_conflict(hypothesis, "reserved_same_page_commit_deferred") + local_pairing_hypotheses.append(hypothesis) candidates.append((gi, group, match_score)) candidates.sort(key=lambda item: item[2]["score"], reverse=True) matched_assets = [] region_match = None ambiguous = False + defer_reserved_same_page_commit = False if candidates: top_score = candidates[0][2]["score"] @@ -1236,7 +2144,9 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 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) + 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: @@ -1294,27 +2204,35 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 best_delta = delta if best[2].get("decision") == "matched": best_gi, best_group, best_score = best - matched_assets = best_group.get("media_blocks", []) - g_page = best_group.get("page", 0) - used_asset_page_ids.update({(g_page, bid) for bid in best_group.get("asset_block_ids", [])}) - used_group_ids.add(str(best_group.get("group_id", ""))) - matched_assets = _expand_matched_assets_locally( - legend, - matched_assets, - assets, - used_asset_page_ids, - page_caption_index.get(g_page, []), - ) - region_match = { - "media_blocks": matched_assets, - "match_score": best_score, - "group_type": best_group.get("group_type", ""), - "group_evidence": best_group.get("group_evidence", []), - } - if len(matched_assets) > 1: - region_match["cluster_bbox"] = best_group.get("cluster_bbox", [0, 0, 0, 0]) + if legend_reserved_for_cross_page or str(best_group.get("group_id", "")) in _reserved_group_ids: + defer_reserved_same_page_commit = True + matched_assets = [] + region_match = None + else: + matched_assets = best_group.get("media_blocks", []) + g_page = best_group.get("page", 0) + ownership.match_group(best_group, owner_id=str(legend.get("block_id", "")), owner_family="figure") + matched_assets = _expand_matched_assets_locally( + legend, + matched_assets, + assets, + ownership, + page_caption_index.get(g_page, []), + ) + region_match = { + "media_blocks": matched_assets, + "match_score": best_score, + "group_type": best_group.get("group_type", ""), + "group_evidence": best_group.get("group_evidence", []), + } + 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: + 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( @@ -1339,27 +2257,34 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 else: best_gi, best_group, best_score = candidates[0] if best_score["decision"] == "matched": - matched_assets = best_group.get("media_blocks", []) - g_page = best_group.get("page", 0) - used_asset_page_ids.update({(g_page, bid) for bid in best_group.get("asset_block_ids", [])}) - used_group_ids.add(str(best_group.get("group_id", ""))) - matched_assets = _expand_matched_assets_locally( - legend, - matched_assets, - assets, - used_asset_page_ids, - page_caption_index.get(g_page, []), - ) - region_match = { - "media_blocks": matched_assets, - "match_score": best_score, - "group_type": best_group.get("group_type", ""), - "group_evidence": best_group.get("group_evidence", []), - } - if len(matched_assets) > 1: - region_match["cluster_bbox"] = best_group.get("cluster_bbox", [0, 0, 0, 0]) + if legend_reserved_for_cross_page or str(best_group.get("group_id", "")) in _reserved_group_ids: + defer_reserved_same_page_commit = True + matched_assets = [] + region_match = None + else: + matched_assets = best_group.get("media_blocks", []) + g_page = best_group.get("page", 0) + ownership.match_group(best_group, owner_id=str(legend.get("block_id", "")), owner_family="figure") + matched_assets = _expand_matched_assets_locally( + legend, + matched_assets, + assets, + ownership, + page_caption_index.get(g_page, []), + ) + region_match = { + "media_blocks": matched_assets, + "match_score": best_score, + "group_type": best_group.get("group_type", ""), + "group_evidence": best_group.get("group_evidence", []), + } + 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"): + 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( @@ -1383,6 +2308,9 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 is_legend_only = len(matched_assets) == 0 + if defer_reserved_same_page_commit: + continue + if caption_score.get("score", 0.0) < 0.4: unmatched_legends.append(legend) continue @@ -1410,7 +2338,8 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 # and let the shared caption claim all assets. if is_weak_truncated: page_truncated_count = sum( - 1 for leg in ordered_legends + 1 + for leg in ordered_legends if leg.get("page", 0) == legend_page and _is_insufficient_legend_evidence(leg) ) if page_truncated_count >= 2: @@ -1445,6 +2374,8 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 # Single truncated legend with matched assets — proceed to confirmation if is_legend_only: + if legend_reserved_for_cross_page: + continue if fig_num is None and str(legend.get("role") or "") in ("figure_caption_candidate", "figure_caption"): unmatched_legends.append(legend) continue @@ -1495,6 +2426,9 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 "match_score": match_score, "flags": [], "caption_score": caption_score, + "legend_page": legend_page, + "asset_pages": sorted({int(a.get("page", 0) or 0) for a in matched_assets}) or [legend_page], + "settlement_type": "same_page", } 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")] @@ -1524,6 +2458,62 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 if not asset_bid or (asset_page, asset_bid) not in used_asset_page_ids: unmatched_assets.append(asset) + # --- Stage 1: primary cross-page settlement before legacy fallback --- + _settle_cross_page_reserved_objects( + _reserved_legend_ids, + _reserved_group_ids, + ordered_legends, + candidate_groups, + structured_blocks, + matched_figures, + ambiguous_figures, + unmatched_legends, + used_group_ids, + used_asset_page_ids, + ownership, + residual_ledger=_residual_ledger, + competing_caption_pages=_competing_caption_pages, + page_width=page_width, + ) + # Rebuild unmatched_assets after cross-page settlement consumed some + unmatched_assets.clear() + for _a in assets: + _ap = _a.get("page", 0) + _bid = _a.get("block_id", "") + if _bid and (_ap, _bid) not in used_asset_page_ids: + unmatched_assets.append(_a) + # Handle failed reserved groups: multi-asset -> unresolved_clusters, single -> unmatched_assets + _failed_reserved_groups = [ + g + for g in candidate_groups + if str(g.get("group_id", "")) in _reserved_group_ids and str(g.get("group_id", "")) not in used_group_ids + ] + for _fg in _failed_reserved_groups: + _fg_type = _fg.get("group_type", "") + _fg_page = int(_fg.get("page", 0) or 0) + _fg_bids = [str(b.get("block_id", "")) for b in _fg.get("media_blocks", []) if b.get("block_id")] + if _fg_type == "single_asset" or len(_fg.get("media_blocks", [])) <= 1: + # single-asset reserved group failure -> remain in unmatched_assets + pass + else: + # multi-asset reserved group failure -> unresolved_clusters + unresolved_clusters.append( + { + "cluster_id": f"unresolved_cluster_{len(unresolved_clusters) + 1:03d}", + "media_block_ids": _fg_bids, + "cluster_bbox": _fg.get("cluster_bbox", [0, 0, 0, 0]), + "page": _fg_page, + "hold_reason": "reserved_cross_page_no_valid_legend", + } + ) + # Remove these bids from unmatched_assets + unmatched_assets = [ + ua + for ua in unmatched_assets + if str(ua.get("block_id", "")) not in _fg_bids or ua.get("page", 0) != _fg_page + ] + # --- end Stage 1 cross-page settlement --- + # Sidecar fallback: for pages with narrow same-column formal captions, # override normal figure matching with caption-band partitioning. # The normal spatial matcher cannot reliably assign assets to narrow @@ -1550,6 +2540,12 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 return float(ys[len(ys) // 2]) if ys else 0.0 page_narrow_matched = [mf for mf in matched_figures if str(mf.get("legend_block_id", "")) in nid_set] + reclaimable_asset_page_ids = { + (int(a.get("page", mf.get("page", sidecar_page)) or 0), str(a.get("block_id", ""))) + for mf in page_narrow_matched + for a in mf.get("matched_assets", []) + if a.get("block_id") not in (None, "") + } violation = len(page_narrow_matched) != len(narrow_set) if not violation and len(page_narrow_matched) > 1: ordered = sorted(page_narrow_matched, key=lambda mf: int(mf.get("figure_number", 0) or 0)) @@ -1563,6 +2559,15 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 page_assets_list = [a for a in assets if a.get("page") == sidecar_page and not a.get("_non_body_media")] if not page_assets_list: continue + protected_asset_page_ids = { + tuple(asset_id) + for hypothesis in local_pairing_hypotheses + if hypothesis.get("legend_block_id") not in nid_set + and hypothesis.get("mode") in {"caption_below", "caption_above"} + and float(hypothesis.get("local_score", 0.0) or 0.0) >= 0.8 + for asset_id in hypothesis.get("would_consume_asset_ids", []) + } + grouped_asset_page_ids = _grouped_asset_page_ids(candidate_groups) sidecar_page_height = float( max((b.get("bbox") or [0, 0, 0, 0])[3] for b in structured_blocks if b.get("page") == sidecar_page) or 1600 ) @@ -1574,7 +2579,39 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 cap_text = str(cap.get("text") or "") fig_num = _extract_figure_number(cap_text) cap_ns = _extract_figure_namespace(cap_text) - band_assets = band_map.get(lid, []) + raw_band_assets = [ + asset + for asset in band_map.get(lid, []) + if (int(asset.get("page", 0) or 0), str(asset.get("block_id", ""))) not in protected_asset_page_ids + ] + band_assets = _caption_row_coupled_assets( + cap, + raw_band_assets, + page_width=page_width, + ) + if not band_assets: + band_assets = raw_band_assets + if not band_assets: + continue + eligible_asset_ids = _fallback_eligible_asset_page_ids( + [ + (int(asset.get("page", 0) or 0), str(asset.get("block_id", ""))) + for asset in band_assets + if asset.get("block_id") not in (None, "") + ], + used_asset_page_ids=used_asset_page_ids - reclaimable_asset_page_ids, + blocked_asset_page_ids=set(), + grouped_asset_page_ids=grouped_asset_page_ids, + allow_grouped=True, + ) + if not eligible_asset_ids: + continue + eligible_asset_id_set = set(eligible_asset_ids) + band_assets = [ + asset + for asset in band_assets + if (int(asset.get("page", 0) or 0), str(asset.get("block_id", ""))) in eligible_asset_id_set + ] if not band_assets: continue for ba in band_assets: @@ -1604,12 +2641,46 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 "caption_score": score_figure_caption( cap, nearby_media=True, caption_style_match=False, body_prose_likelihood=False ), + "legend_page": sidecar_page, + "asset_pages": [sidecar_page], + "settlement_type": "sidecar", } + local_pairing_hypotheses.append( + _make_local_pairing_hypothesis( + cap, + { + "group_id": f"sidecar_partition:{sidecar_page}:{lid}", + "page": sidecar_page, + "media_blocks": band_assets, + }, + mode="caption_sidecar", + local_score=0.5, + evidence=["same_row_alignment", "narrow_caption_column", "sidecar_fallback"], + ) + ) if len(band_assets) > 1: sidecar_entry["cluster_bbox"] = _cluster_bbox([a.get("bbox", [0, 0, 0, 0]) for a in band_assets]) sidecar_promoted.append(sidecar_entry) if not sidecar_promoted: continue + reclaimable_group_ids = { + str(group.get("group_id", "")) + for group in candidate_groups + if str(group.get("group_id", "")) + and all( + aid in reclaimable_asset_page_ids + for aid in [ + _asset_page_id(group.get("page", 0), bid) + for bid in group.get("asset_block_ids", []) + if bid is not None + ] + if aid is not None + ) + } + for asset_id in reclaimable_asset_page_ids: + used_asset_page_ids.discard(asset_id) + for group_id in reclaimable_group_ids: + used_group_ids.discard(group_id) matched_figures = [mf for mf in matched_figures if str(mf.get("legend_block_id", "")) not in nid_set] for legend in list(legends): if str(legend.get("block_id", "")) in nid_set: @@ -1622,12 +2693,13 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 ul for ul in unmatched_legends if str(ul.get("block_id", "")) != str(legend.get("block_id", "")) ] matched_figures.extend(sidecar_promoted) - used_asset_page_ids.update(sidecar_consumed_ids) + ownership.mark_assets_owned(list(sidecar_consumed_ids), owner_id=f"sidecar_page_{sidecar_page}", owner_family="figure") # 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: + grouped_asset_page_ids = _grouped_asset_page_ids(candidate_groups) page_captions: dict[int, list[dict]] = {} for leg in unmatched_legends: cp = int(leg.get("page", 0) or 0) @@ -1636,9 +2708,7 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 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 - ) + 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]) @@ -1647,6 +2717,8 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 for ast in unmatched_assets: ap = int(ast.get("page", 0) or 0) bid = ast.get("block_id", "") + if (ap, str(bid)) in grouped_asset_page_ids: + continue if ap <= cp: continue if bid and (ap, bid) in used_asset_page_ids: @@ -1655,9 +2727,17 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 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"} + _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 @@ -1668,13 +2748,12 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 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 + 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)] + caps_sorted = caps_sorted[: len(valid_pages)] if not valid_pages: continue # Match captions to validated asset pages in order @@ -1695,30 +2774,40 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 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]), - }) + ownership.mark_assets_owned([(ap, bid)], owner_id=str(cap.get("block_id", "")), owner_family="figure") + 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"], - }) + cp_page = int(cap.get("page", 0) or 0) + 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"], + "legend_page": cp_page, + "asset_pages": [ap], + "settlement_type": "legend_bundle", + } + ) # 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", [])} + 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] # === Group-aware sequential fallback === @@ -1728,20 +2817,25 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 for block in _filter_figure_assets(assets): local_asset_by_page_id[(int(block.get("page", 0) or 0), str(block.get("block_id", "")))] = block + # ponytail: also include single_asset groups per Option 1 — group-aware fallback + # handles them so old sequential fallback only sees true bare assets. unmatched_groups = [ - g for g in candidate_groups + g + for g in candidate_groups if str(g.get("group_id", "")) not in used_group_ids - and g.get("group_type") == "distance_cluster" + and g.get("group_type") in {"distance_cluster", "single_asset"} and not any( (int(g.get("page", 0) or 0), str(bid)) in used_asset_page_ids for bid in g.get("asset_block_ids", []) if bid is not None ) ] - unmatched_groups.sort(key=lambda g: ( - int(g.get("page", 0) or 0), - (g.get("cluster_bbox") or [0, 0, 0, 0])[1], - )) + unmatched_groups.sort( + key=lambda g: ( + int(g.get("page", 0) or 0), + (g.get("cluster_bbox") or [0, 0, 0, 0])[1], + ) + ) for legend in list(unmatched_legends): lg_page = int(legend.get("page", 0) or 0) @@ -1761,9 +2855,12 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 if same_page: for sg in same_page: sg_score = _score_legend_to_group( - legend, sg, + legend, + sg, caption_score=score_figure_caption( - legend, nearby_media=True, caption_style_match=False, + legend, + nearby_media=True, + caption_style_match=False, body_prose_likelihood=False, ), page_width=page_width, @@ -1784,7 +2881,9 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 group_page = int(best_group.get("page", 0) or 0) caption_score = score_figure_caption( - legend, nearby_media=True, caption_style_match=False, + legend, + nearby_media=True, + caption_style_match=False, body_prose_likelihood=False, ) @@ -1795,42 +2894,45 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 asset = local_asset_by_page_id.get((group_page, str(bid))) if asset: group_assets.append(asset) - used_asset_page_ids.add((group_page, str(bid))) + ownership.mark_assets_owned([(group_page, str(bid))], owner_id=str(legend.get("block_id", "")), owner_family="figure") if not group_assets: continue - matched_figures.append({ - "figure_id": fig_id, - "figure_namespace": cap_ns, - "legend_block_id": legend.get("block_id", ""), - "page": group_page, - "text": cap_text, - "figure_number": fn, - "matched_assets": [ - {"block_id": a.get("block_id", ""), "bbox": a.get("bbox", [0, 0, 0, 0])} - for a in group_assets - ], - "asset_block_ids": [str(a.get("block_id", "")) for a in group_assets], - "bridge_block_ids": [], - "group_type": best_group.get("group_type", ""), - "group_evidence": best_group.get("group_evidence", []) + ["group_sequential_fallback"], - "cluster_bbox": best_group.get("cluster_bbox", [0, 0, 0, 0]), - "confidence": 0.45, - "match_score": { - "score": 0.45, - "decision": "matched", - "evidence": ["group_sequential_fallback"], - }, - "flags": ["group_sequential_match"], - "caption_score": caption_score, - }) + matched_figures.append( + { + "figure_id": fig_id, + "figure_namespace": cap_ns, + "legend_block_id": legend.get("block_id", ""), + "page": group_page, + "text": cap_text, + "figure_number": fn, + "matched_assets": [ + {"block_id": a.get("block_id", ""), "bbox": a.get("bbox", [0, 0, 0, 0])} for a in group_assets + ], + "asset_block_ids": [str(a.get("block_id", "")) for a in group_assets], + "bridge_block_ids": [], + "group_type": best_group.get("group_type", ""), + "group_evidence": best_group.get("group_evidence", []) + ["group_sequential_fallback"], + "cluster_bbox": best_group.get("cluster_bbox", [0, 0, 0, 0]), + "confidence": 0.45, + "match_score": { + "score": 0.45, + "decision": "matched", + "evidence": ["group_sequential_fallback"], + }, + "flags": ["group_sequential_match"], + "caption_score": caption_score, + "legend_page": int(legend.get("page", 0) or 0), + "asset_pages": sorted({int(a.get("page", 0) or 0) for a in group_assets}), + "settlement_type": "group_sequential", + } + ) - used_group_ids.add(str(best_group.get("group_id", ""))) + ownership.match_group(best_group, owner_id=str(legend.get("block_id", "")), owner_family="figure") unmatched_legends.remove(legend) ambiguous_figures[:] = [ - af for af in ambiguous_figures - if str(af.get("legend_block_id", "")) != str(legend.get("block_id", "")) + af for af in ambiguous_figures if str(af.get("legend_block_id", "")) != str(legend.get("block_id", "")) ] # === End group-aware fallback === @@ -1839,13 +2941,21 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 # Captions and figures often appear on different pages — humans match them by # sequential reading order, not spatial proximity. This is a necessary tradeoff. # Run BEFORE cluster building so sequential matching gets first pick of assets. - if unmatched_legends and unmatched_assets: + # Stage 1 restriction: sequential fallback may not consume assets that belong + # to any candidate group. Filter the input to the loop but keep unmatched_assets for output. + _grouped_asset_ids = _grouped_asset_page_ids(candidate_groups) + _ungrouped_unmatched = [ + ua + for ua in unmatched_assets + if (int(ua.get("page", 0) or 0), str(ua.get("block_id", ""))) not in _grouped_asset_ids + ] + if unmatched_legends and _ungrouped_unmatched: sorted_caps = sorted( unmatched_legends, key=lambda b: (b.get("page", 0) or 0, (b.get("bbox") or [0, 0, 0, 0])[1]), ) sorted_asts = sorted( - unmatched_assets, + _ungrouped_unmatched, key=lambda b: (b.get("page", 0) or 0, (b.get("bbox") or [0, 0, 0, 0])[1]), ) ai = 0 @@ -1929,10 +3039,13 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 "match_score": {"score": 0.35, "decision": "matched", "evidence": ["sequential_fallback"]}, "flags": ["sequential_match"], "caption_score": caption_score, + "legend_page": int(cap.get("page", 0) or 0), + "asset_pages": [int(asset.get("page", 0) or 0)], + "settlement_type": "sequential", } ) if asset_bid: - used_asset_page_ids.add((asset_page, asset_bid)) + ownership.mark_assets_owned([(asset_page, asset_bid)], owner_id=str(cap.get("block_id", "")), owner_family="figure") for cap, asset in seq_matched: unmatched_legends[:] = [l for l in unmatched_legends if l is not cap] if int(asset.get("page", 0) or 0) < int(cap.get("page", 0) or 0): @@ -1965,12 +3078,15 @@ def build_figure_inventory(structured_blocks: list[dict], page_width: float = 12 consumed = {bid for uc in unresolved_clusters for bid in uc["media_block_ids"]} unmatched_assets = [a for a in unmatched_assets if a.get("block_id", "") not in consumed] + unmatched_assets = _recompute_final_unmatched_assets(assets, used_asset_page_ids, unresolved_clusters) + inventory = { "figure_legends": deduped_legends, "figure_assets": assets, "matched_figures": matched_figures, "held_figures": held_figures, "ambiguous_figures": ambiguous_figures, + "local_pairing_hypotheses": local_pairing_hypotheses, "unmatched_legends": unmatched_legends, "unmatched_assets": unmatched_assets, "rejected_legends": rejected_legends, @@ -2159,15 +3275,26 @@ def _promote_sequence_matches(figure_inventory: dict, blocks: list[dict]) -> dic af["sequence_skip_empty_assets"] = True remaining_ambiguous.append(af) continue + matched_assets = list(af.get("matched_assets", [])) + asset_pages = list(af.get("asset_pages", [])) + legend_page = af.get("legend_page", af.get("page")) + page = af.get("page") + if not matched_assets or not asset_pages or legend_page is None or page is None: + af["sequence_skip_incomplete_contract"] = True + remaining_ambiguous.append(af) + continue ns_promoted = _extract_figure_namespace(af.get("text", "")) promoted_entry = { "figure_id": _format_figure_id(ns_promoted, fn), "figure_namespace": ns_promoted, "legend_block_id": af.get("legend_block_id", ""), - "page": af.get("page"), + "page": page, + "legend_page": legend_page, + "asset_pages": asset_pages, "text": af.get("text", ""), "figure_number": fn, - "matched_assets": [], + "matched_assets": matched_assets, + "asset_block_ids": asset_block_ids, "group_type": "", "group_evidence": [], "confidence": 0.0, @@ -2179,6 +3306,7 @@ def _promote_sequence_matches(figure_inventory: dict, blocks: list[dict]) -> dic "flags": ["sequence_match"], "caption_score": af.get("caption_score", {}), "strict_status": "sequence_match", + "settlement_type": "sequence_match", "zone": af.get("zone"), "style_family": af.get("style_family"), "marker_signature": af.get("marker_signature") or {}, @@ -2195,6 +3323,50 @@ def _promote_sequence_matches(figure_inventory: dict, blocks: list[dict]) -> dic return figure_inventory +def _collect_figure_owned_asset_ids(figure_inventory: dict) -> set[tuple[int, str]]: + owned: set[tuple[int, str]] = set() + for figure in figure_inventory.get("matched_figures", []): + asset_page = int(figure.get("page", figure.get("legend_page", 0)) or 0) + for asset in figure.get("matched_assets", []): + asset_id = _asset_page_id(asset_page, asset.get("block_id", "")) + if asset_id is not None: + owned.add(asset_id) + return owned + + +def _collect_table_owned_asset_ids(table_inventory: dict) -> set[tuple[int, str]]: + owned: set[tuple[int, str]] = set() + for table in table_inventory.get("tables", []): + if not table.get("has_asset"): + continue + asset_id = _asset_page_id(table.get("page", 0), table.get("asset_block_id", "")) + if asset_id is not None: + owned.add(asset_id) + return owned + + +def _build_ownership_conflicts(figure_inventory: dict, table_inventory: dict) -> list[dict]: + figure_owned = _collect_figure_owned_asset_ids(figure_inventory) + table_owned = _collect_table_owned_asset_ids(table_inventory) + conflicts: list[dict] = [] + for asset_page_id in sorted(figure_owned & table_owned): + page, block_id = asset_page_id + conflicts.append( + { + "asset_page_id": asset_page_id, + "page": page, + "block_id": block_id, + "conflict_type": "ownership_conflict", + "owners": ["figure", "table"], + } + ) + return conflicts + + +def attach_ownership_conflicts(figure_inventory: dict, table_inventory: dict) -> None: + figure_inventory["ownership_conflicts"] = _build_ownership_conflicts(figure_inventory, table_inventory) + + def write_back_figure_roles(inventory: dict, structured_blocks: list[dict]) -> None: """Update structured block roles for matched figures from media_asset to figure_asset.""" for figure in inventory.get("matched_figures", []): diff --git a/tests/test_ocr_figure_reader.py b/tests/test_ocr_figure_reader.py index b7afe3a5..02e0b59d 100644 --- a/tests/test_ocr_figure_reader.py +++ b/tests/test_ocr_figure_reader.py @@ -388,3 +388,39 @@ def test_reader_materializes_grouped_strict_match_as_single_visual_group() -> No assert len(rf["visual_groups"]) == 1 assert rf["visual_groups"][0]["asset_block_ids"] == [20, 21] assert rf["visual_groups"][0]["group_status"] == "matched_group" + + +def test_reader_matched_cross_page_figure_consumes_caption_on_legend_page() -> None: + from paperforge.worker.ocr_figure_reader import synthesize_reader_figures + + strict_inventory = { + "figure_legends": [], + "matched_figures": [ + { + "figure_id": "figure_004", + "figure_number": 4, + "legend_block_id": 6, + "page": 12, + "legend_page": 13, + "asset_pages": [12], + "text": "Figure 4. Cross-page caption.", + "matched_assets": [ + {"block_id": 101, "bbox": [100, 100, 300, 300]}, + ], + "asset_block_ids": [101], + "strict_status": "matched", + "match_score": {"score": 0.8, "decision": "matched"}, + } + ], + "held_figures": [], + "ambiguous_figures": [], + "unmatched_legends": [], + "unresolved_clusters": [], + } + + payload = synthesize_reader_figures(strict_inventory, structured_blocks=[]) + + rf = payload["reader_figures"][0] + assert rf["visual_groups"][0]["page"] == 12 + assert rf["consumed_caption_block_ids"] == [{"page": 13, "block_id": 6}] + assert payload["consumed_caption_block_ids"] == [{"page": 13, "block_id": 6}] diff --git a/tests/test_ocr_figures.py b/tests/test_ocr_figures.py index 18131b83..cb49bce5 100644 --- a/tests/test_ocr_figures.py +++ b/tests/test_ocr_figures.py @@ -30,6 +30,207 @@ def test_cluster_bbox_empty() -> None: assert result == [0, 0, 0, 0] +def test_ownership_registry_blocked_asset_requires_reason() -> None: + from paperforge.worker.ocr_figures import FigureOwnershipRegistry + + registry = FigureOwnershipRegistry() + + try: + registry.block_asset((1, "asset_1"), reason="") + except ValueError as exc: + assert "reason" in str(exc) + else: + raise AssertionError("Expected block_asset to reject empty reasons") + + +def test_ownership_registry_rejects_conflicting_asset_owners() -> None: + from paperforge.worker.ocr_figures import FigureOwnershipRegistry + + registry = FigureOwnershipRegistry() + + registry.mark_assets_owned([(1, "asset_1")], owner_id="figure_001", owner_family="figure") + + try: + registry.mark_assets_owned([(1, "asset_1")], owner_id="table_001", owner_family="table") + except ValueError as exc: + assert "asset_1" in str(exc) + else: + raise AssertionError("Expected conflicting owner families to be rejected") + + +def test_ownership_registry_mirrors_used_sets_for_group_match() -> None: + from paperforge.worker.ocr_figures import FigureOwnershipRegistry + + used_group_ids: set[str] = set() + used_asset_page_ids: set[tuple[int, str]] = set() + registry = FigureOwnershipRegistry(used_group_ids=used_group_ids, used_asset_page_ids=used_asset_page_ids) + group = { + "group_id": "group_001", + "page": 3, + "asset_block_ids": ["asset_1", "asset_2"], + "media_blocks": [ + {"page": 3, "block_id": "asset_1"}, + {"page": 3, "block_id": "asset_2"}, + ], + } + + registry.match_group(group, owner_id="figure_001", owner_family="figure") + + assert used_group_ids == {"group_001"} + assert used_asset_page_ids == {(3, "asset_1"), (3, "asset_2")} + assert registry.used_group_ids == used_group_ids + assert registry.used_asset_page_ids == used_asset_page_ids + + +def test_make_local_pairing_hypothesis_caption_below() -> None: + from paperforge.worker.ocr_figures import _make_local_pairing_hypothesis + + legend = {"block_id": "cap_1", "page": 2, "bbox": [100, 420, 700, 500]} + group = { + "group_id": "group_1", + "page": 2, + "asset_block_ids": ["asset_1", "asset_2"], + "media_blocks": [ + {"page": 2, "block_id": "asset_1"}, + {"page": 2, "block_id": "asset_2"}, + ], + } + + hypothesis = _make_local_pairing_hypothesis( + legend, + group, + mode="caption_below", + local_score=0.88, + evidence=["same_page", "vertical_proximity"], + ) + + assert hypothesis["legend_block_id"] == "cap_1" + assert hypothesis["group_id"] == "group_1" + assert hypothesis["mode"] == "caption_below" + assert hypothesis["local_score"] == 0.88 + assert hypothesis["evidence"] == ["same_page", "vertical_proximity"] + assert hypothesis["conflicts"] == [] + assert hypothesis["would_consume_asset_ids"] == [(2, "asset_1"), (2, "asset_2")] + + +def test_make_local_pairing_hypothesis_caption_above() -> None: + from paperforge.worker.ocr_figures import _make_local_pairing_hypothesis + + legend = {"block_id": "cap_2", "page": 4, "bbox": [100, 80, 700, 150]} + group = { + "group_id": "group_2", + "page": 4, + "asset_block_ids": ["asset_3"], + "media_blocks": [{"page": 4, "block_id": "asset_3"}], + } + + hypothesis = _make_local_pairing_hypothesis( + legend, + group, + mode="caption_above", + local_score=0.73, + evidence=["same_page", "caption_above_geometry"], + conflicts=["mixed_page_layout"], + ) + + assert hypothesis["mode"] == "caption_above" + assert hypothesis["local_score"] == 0.73 + assert hypothesis["conflicts"] == ["mixed_page_layout"] + assert hypothesis["would_consume_asset_ids"] == [(4, "asset_3")] + + +def test_make_local_pairing_hypothesis_caption_sidecar() -> None: + from paperforge.worker.ocr_figures import _make_local_pairing_hypothesis + + legend = {"block_id": "cap_3", "page": 6, "bbox": [60, 100, 320, 180]} + group = { + "group_id": "sidecar_6_cap_3", + "page": 6, + "asset_block_ids": ["asset_8"], + "media_blocks": [{"page": 6, "block_id": "asset_8"}], + } + + hypothesis = _make_local_pairing_hypothesis( + legend, + group, + mode="caption_sidecar", + local_score=0.65, + evidence=["same_row_alignment", "narrow_caption_column"], + ) + + assert hypothesis["mode"] == "caption_sidecar" + assert hypothesis["would_consume_asset_ids"] == [(6, "asset_8")] + + +def test_build_figure_inventory_exposes_mixed_local_pairing_hypotheses() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"paper_id": "MX", "page": 1, "block_id": "a1", "role": "media_asset", "raw_label": "image", "bbox": [520, 80, 1040, 360], "page_width": 1200, "page_height": 1600}, + {"paper_id": "MX", "page": 1, "block_id": "a2", "role": "media_asset", "raw_label": "image", "bbox": [520, 410, 1040, 690], "page_width": 1200, "page_height": 1600}, + {"paper_id": "MX", "page": 1, "block_id": "a3", "role": "media_asset", "raw_label": "image", "bbox": [520, 760, 1040, 980], "page_width": 1200, "page_height": 1600}, + {"paper_id": "MX", "page": 1, "block_id": "a4", "role": "media_asset", "raw_label": "image", "bbox": [120, 820, 1080, 1080], "page_width": 1200, "page_height": 1600}, + {"paper_id": "MX", "page": 1, "block_id": "body_sep", "role": "body_paragraph", "text": "This body text separates the sidecar pair from the lower figure.", "bbox": [120, 700, 1080, 790], "page_width": 1200, "page_height": 1600}, + { + "paper_id": "MX", + "page": 1, + "block_id": "cap1", + "role": "figure_caption_candidate", + "seed_role": "figure_caption", + "raw_label": "figure_title", + "text": "Fig. 1. Left sidecar figure.", + "bbox": [80, 110, 360, 180], + "page_width": 1200, + "page_height": 1600, + "zone": "display_zone", + "style_family": "legend_like", + "marker_signature": {"type": "figure_number", "number": 1}, + }, + { + "paper_id": "MX", + "page": 1, + "block_id": "cap2", + "role": "figure_caption_candidate", + "seed_role": "figure_caption", + "raw_label": "figure_title", + "text": "Fig. 2. Lower sidecar figure.", + "bbox": [80, 440, 360, 510], + "page_width": 1200, + "page_height": 1600, + "zone": "display_zone", + "style_family": "legend_like", + "marker_signature": {"type": "figure_number", "number": 2}, + }, + { + "paper_id": "MX", + "page": 1, + "block_id": "cap3", + "role": "figure_caption_candidate", + "seed_role": "figure_caption", + "raw_label": "figure_title", + "text": "Fig. 3. Full-width caption below.", + "bbox": [130, 1100, 1090, 1180], + "page_width": 1200, + "page_height": 1600, + "zone": "display_zone", + "style_family": "legend_like", + "marker_signature": {"type": "figure_number", "number": 3}, + }, + ] + + inventory = build_figure_inventory(blocks) + + hypotheses = inventory["local_pairing_hypotheses"] + modes_by_legend: dict[str, set[str]] = {} + for item in hypotheses: + modes_by_legend.setdefault(item["legend_block_id"], set()).add(item["mode"]) + + assert "caption_sidecar" in modes_by_legend["cap1"] + assert "caption_sidecar" in modes_by_legend["cap2"] + assert "caption_below" in modes_by_legend["cap3"] + assert len(inventory["matched_figures"]) == 3 + + def test_media_clusters_side_by_side() -> None: from paperforge.worker.ocr_figures import _media_clusters @@ -208,6 +409,115 @@ def test_cluster_page_assets_no_irregular_when_multi_legend() -> None: assert len(clusters) == 2 +def test_semantic_group_topology_uses_asset_block_ids_not_group_id() -> None: + from paperforge.worker.ocr_figures import _semantic_group_topology + + groups_a = [ + {"group_id": "group_0001", "asset_block_ids": ["a1", "a2"]}, + {"group_id": "group_0002", "asset_block_ids": ["b1"]}, + ] + groups_b = [ + {"group_id": "totally_different", "asset_block_ids": ["b1"]}, + {"group_id": "another_one", "asset_block_ids": ["a2", "a1"]}, + ] + + assert _semantic_group_topology(groups_a) == _semantic_group_topology(groups_b) + + +def test_semantic_grouping_topology_is_caption_count_independent() -> None: + from paperforge.worker.ocr_figures import ( + _build_semantic_figure_groups_from_assets, + _semantic_group_topology, + ) + + assets = [ + {"block_id": "a1", "page": 1, "role": "figure_asset", "bbox": [100, 100, 300, 240]}, + {"block_id": "a2", "page": 1, "role": "figure_asset", "bbox": [320, 100, 520, 240]}, + {"block_id": "a3", "page": 1, "role": "figure_asset", "bbox": [100, 420, 300, 560]}, + {"block_id": "a4", "page": 1, "role": "figure_asset", "bbox": [320, 420, 520, 560]}, + ] + one_caption_blocks = assets + [ + {"block_id": "cap1", "page": 1, "role": "figure_caption", "text": "Figure 1. Caption.", "bbox": [100, 600, 520, 660]}, + ] + two_caption_blocks = assets + [ + {"block_id": "cap1", "page": 1, "role": "figure_caption", "text": "Figure 1. Caption.", "bbox": [100, 280, 520, 340]}, + {"block_id": "cap2", "page": 1, "role": "figure_caption", "text": "Figure 2. Caption.", "bbox": [100, 600, 520, 660]}, + ] + + one_caption = _build_semantic_figure_groups_from_assets(assets, one_caption_blocks, page_width=1200) + two_captions = _build_semantic_figure_groups_from_assets(assets, two_caption_blocks, page_width=1200) + + assert _semantic_group_topology(one_caption) == _semantic_group_topology(two_captions) + + +def test_semantic_grouping_keeps_two_visual_figures_separate() -> None: + from paperforge.worker.ocr_figures import _build_semantic_figure_groups_from_assets + + assets = [ + {"block_id": "left_1", "page": 1, "role": "figure_asset", "bbox": [100, 100, 300, 260]}, + {"block_id": "left_2", "page": 1, "role": "figure_asset", "bbox": [100, 280, 300, 440]}, + {"block_id": "right_1", "page": 1, "role": "figure_asset", "bbox": [700, 100, 900, 260]}, + {"block_id": "right_2", "page": 1, "role": "figure_asset", "bbox": [700, 280, 900, 440]}, + ] + + groups = _build_semantic_figure_groups_from_assets(assets, assets, page_width=1200) + + assert len(groups) == 2 + assert {frozenset(group["asset_block_ids"]) for group in groups} == { + frozenset({"left_1", "left_2"}), + frozenset({"right_1", "right_2"}), + } + + +def test_semantic_grouping_uses_caption_text_as_neutral_barrier_only() -> None: + from paperforge.worker.ocr_figures import _build_semantic_figure_groups_from_assets + + assets = [ + {"block_id": "top", "page": 1, "role": "figure_asset", "bbox": [100, 100, 420, 240]}, + {"block_id": "bottom", "page": 1, "role": "figure_asset", "bbox": [100, 420, 420, 560]}, + ] + blocks = assets + [ + { + "block_id": "caption_barrier", + "page": 1, + "role": "body_paragraph", + "text": "Figure caption barrier text that separates the two visual regions.", + "bbox": [110, 270, 410, 390], + } + ] + + groups = _build_semantic_figure_groups_from_assets(assets, blocks, page_width=1200) + + assert len(groups) == 2 + assert {frozenset(group["asset_block_ids"]) for group in groups} == { + frozenset({"top"}), + frozenset({"bottom"}), + } + + +def test_candidate_groups_do_not_include_caption_band_local_groups() -> None: + from paperforge.worker.ocr_figures import _build_candidate_figure_groups_from_assets + + assets = [ + {"block_id": "a1", "page": 1, "role": "figure_asset", "bbox": [100, 100, 300, 240]}, + {"block_id": "a2", "page": 1, "role": "figure_asset", "bbox": [320, 100, 520, 240]}, + {"block_id": "a3", "page": 1, "role": "figure_asset", "bbox": [100, 420, 300, 560]}, + {"block_id": "a4", "page": 1, "role": "figure_asset", "bbox": [320, 420, 520, 560]}, + ] + legends = [ + {"block_id": "cap1", "page": 1, "role": "figure_caption", "text": "Figure 1. Caption.", "bbox": [100, 280, 520, 340]}, + {"block_id": "cap2", "page": 1, "role": "figure_caption", "text": "Figure 2. Caption.", "bbox": [100, 600, 520, 660]}, + ] + + candidate_groups = _build_candidate_figure_groups_from_assets(assets, assets + legends, legends, page_width=1200) + + assert len(candidate_groups) == 2 + assert {frozenset(group["asset_block_ids"]) for group in candidate_groups} == { + frozenset({"a1", "a2"}), + frozenset({"a3", "a4"}), + } + + def test_precaption_media_region_above() -> None: from paperforge.worker.ocr_figures import _precaption_media_region @@ -649,15 +959,13 @@ def test_legend_does_not_steal_offpage_asset() -> None: inventory = build_figure_inventory(structured_blocks) - # Sequential fallback intentionally cross-pages captions to remaining assets + # Group-aware or sequential fallback matches cross-page captions to remaining assets # when no same-page candidates exist. This is by design, not a bug. assert len(inventory["matched_figures"]) == 1 - assert "sequential_match" in inventory["matched_figures"][0].get("flags", []) - assert len(inventory.get("ambiguous_figures", [])) == 1 - assert inventory["ambiguous_figures"][0]["legend_block_id"] == "p1_b1" - assert inventory["ambiguous_figures"][0]["hold_reason"] == "no_asset_match" - assert len(inventory["unmatched_assets"]) == 1 - assert inventory["unmatched_assets"][0]["block_id"] == "p2_b1" + mf_flags = inventory["matched_figures"][0].get("flags", []) + assert "sequential_match" in mf_flags or "group_sequential_match" in mf_flags or "cross_page_match" in mf_flags + assert len(inventory.get("unmatched_legends", [])) == 0 + assert len(inventory["unmatched_assets"]) == 0 # --- shared fixture for unresolved cluster tests --- @@ -2040,6 +2348,51 @@ def test_sequence_match_requires_at_least_one_asset_block_id() -> None: assert 3 in fig_numbers +def test_sequence_match_promoted_entry_carries_full_contract() -> None: + from paperforge.worker.ocr_figures import _promote_sequence_matches + + inventory = { + "matched_figures": [ + { + "figure_number": 1, + "legend_block_id": "cap1", + "page": 3, + "legend_page": 3, + "asset_pages": [3], + "matched_assets": [{"block_id": "asset_1", "bbox": [0, 0, 10, 10]}], + "asset_block_ids": ["asset_1"], + "settlement_type": "same_page", + } + ], + "ambiguous_figures": [ + { + "figure_number": 2, + "legend_block_id": "cap2", + "page": 4, + "legend_page": 4, + "text": "Figure 2. Promoted sequence figure.", + "matched_assets": [{"block_id": "asset_2", "bbox": [1, 1, 20, 20]}], + "asset_block_ids": ["asset_2"], + "asset_pages": [4], + "settlement_type": "group_sequential", + "group_type": "single_asset", + "group_evidence": ["group_sequential_fallback"], + "caption_score": {"score": 0.7}, + } + ], + } + + promoted = _promote_sequence_matches(inventory, blocks=[]) + seq = [m for m in promoted["matched_figures"] if m.get("figure_number") == 2][0] + + assert seq["page"] == 4 + assert seq["legend_page"] == 4 + assert seq["asset_pages"] == [4] + assert seq["matched_assets"][0]["block_id"] == "asset_2" + assert seq["asset_block_ids"] == ["asset_2"] + assert seq["settlement_type"] == "sequence_match" + + def test_reader_figures_never_include_empty_visual_groups() -> None: """No reader figure may have an empty visual_groups list.""" from paperforge.worker.ocr_figure_reader import synthesize_reader_figures @@ -2138,6 +2491,109 @@ def test_sequential_fallback_does_not_split_grouped_assets() -> None: ) +def test_fallback_eligible_asset_page_ids_rejects_preowned_assets() -> None: + from paperforge.worker.ocr_figures import _fallback_eligible_asset_page_ids + + asset_ids = [(2, "asset_1"), (2, "asset_2")] + + eligible = _fallback_eligible_asset_page_ids( + asset_ids, + used_asset_page_ids={(2, "asset_2")}, + blocked_asset_page_ids=set(), + ) + + assert eligible == [(2, "asset_1")] + + +def test_fallback_eligible_asset_page_ids_rejects_grouped_assets_by_default() -> None: + from paperforge.worker.ocr_figures import _fallback_eligible_asset_page_ids + + asset_ids = [(3, "asset_1"), (3, "asset_2")] + + eligible = _fallback_eligible_asset_page_ids( + asset_ids, + used_asset_page_ids=set(), + blocked_asset_page_ids=set(), + grouped_asset_page_ids={(3, "asset_2")}, + ) + + assert eligible == [(3, "asset_1")] + + +def test_fallback_eligible_groups_rejects_preowned_groups() -> None: + from paperforge.worker.ocr_figures import _fallback_eligible_groups + + groups = [ + {"group_id": "g1", "page": 5, "asset_block_ids": ["a1"], "group_type": "single_asset"}, + {"group_id": "g2", "page": 5, "asset_block_ids": ["a2"], "group_type": "single_asset"}, + ] + + eligible = _fallback_eligible_groups( + groups, + used_group_ids={"g2"}, + used_asset_page_ids=set(), + ) + + assert [g["group_id"] for g in eligible] == ["g1"] + + +def test_fallback_can_consume_rejects_blocked_assets() -> None: + from paperforge.worker.ocr_figures import _fallback_can_consume + + assert _fallback_can_consume( + [(7, "asset_1")], + used_asset_page_ids=set(), + blocked_asset_page_ids={(7, "asset_1")}, + ) is False + + +def test_sequential_fallback_skips_preowned_bare_asset() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "cap1", "page": 1, "role": "figure_caption", "text": "Figure 1. Same-page figure.", "bbox": [100, 420, 800, 470], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 1}}, + {"block_id": "asset_1", "page": 1, "role": "figure_asset", "raw_label": "image", "bbox": [100, 100, 500, 380]}, + {"block_id": "cap2", "page": 2, "role": "figure_caption", "text": "Figure 2. Later caption.", "bbox": [100, 120, 800, 170], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 2}}, + ] + + inv = build_figure_inventory(blocks) + matched = {m.get("figure_number"): m for m in inv["matched_figures"]} + + assert 1 in matched + assert 2 not in matched + + +def test_build_ownership_conflicts_surfaces_figure_table_overlap() -> None: + from paperforge.worker.ocr_figures import _build_ownership_conflicts + + figure_inventory = { + "matched_figures": [ + { + "figure_id": "figure_001", + "page": 5, + "legend_page": 5, + "matched_assets": [{"block_id": "shared_asset"}], + "asset_block_ids": ["shared_asset"], + } + ] + } + table_inventory = { + "tables": [ + { + "caption_block_id": "table_cap_1", + "page": 5, + "asset_block_id": "shared_asset", + "has_asset": True, + } + ] + } + + conflicts = _build_ownership_conflicts(figure_inventory, table_inventory) + + assert len(conflicts) == 1 + assert conflicts[0]["asset_page_id"] == (5, "shared_asset") + + # === Task 6: Health counters === @@ -2349,6 +2805,27 @@ def test_panel_labels_do_not_form_sidecar_caption_column() -> None: assert inventory["matched_figures"][0]["figure_number"] == 1 +def test_mixed_page_keeps_sidecar_and_ordinary_below_pairs_separate() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"paper_id": "MX2", "page": 1, "block_id": "a1", "role": "media_asset", "raw_label": "image", "bbox": [520, 80, 960, 280], "page_width": 1200, "page_height": 1600}, + {"paper_id": "MX2", "page": 1, "block_id": "a2", "role": "media_asset", "raw_label": "image", "bbox": [520, 340, 960, 540], "page_width": 1200, "page_height": 1600}, + {"paper_id": "MX2", "page": 1, "block_id": "body_sep", "role": "body_paragraph", "text": "Body separator between local layouts.", "bbox": [120, 620, 1080, 760], "page_width": 1200, "page_height": 1600}, + {"paper_id": "MX2", "page": 1, "block_id": "a3", "role": "media_asset", "raw_label": "image", "bbox": [140, 820, 1060, 1080], "page_width": 1200, "page_height": 1600}, + {"paper_id": "MX2", "page": 1, "block_id": "cap1", "role": "figure_caption_candidate", "seed_role": "figure_caption", "raw_label": "figure_title", "text": "Figure 1. Sidecar top.", "bbox": [80, 90, 320, 150], "page_width": 1200, "page_height": 1600, "zone": "display_zone", "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 1}}, + {"paper_id": "MX2", "page": 1, "block_id": "cap2", "role": "figure_caption_candidate", "seed_role": "figure_caption", "raw_label": "figure_title", "text": "Figure 2. Sidecar lower.", "bbox": [80, 350, 320, 410], "page_width": 1200, "page_height": 1600, "zone": "display_zone", "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 2}}, + {"paper_id": "MX2", "page": 1, "block_id": "cap3", "role": "figure_caption", "raw_label": "figure_title", "text": "Figure 3. Ordinary caption below.", "bbox": [160, 1100, 1040, 1170], "page_width": 1200, "page_height": 1600, "zone": "display_zone", "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 3}}, + ] + + inventory = build_figure_inventory(blocks) + by_num = {item["figure_number"]: item for item in inventory["matched_figures"]} + + assert {a["block_id"] for a in by_num[1]["matched_assets"]} == {"a1"} + assert {a["block_id"] for a in by_num[2]["matched_assets"]} == {"a2"} + assert {a["block_id"] for a in by_num[3]["matched_assets"]} == {"a3"} + + def test_partition_assets_by_caption_bands_keeps_assets_local_to_caption_band() -> None: from paperforge.worker.ocr_figures import _partition_assets_by_caption_bands @@ -2457,3 +2934,428 @@ def test_display_cluster_keeps_empty_bridge_between_asset_and_caption() -> None: matched = inventory["matched_figures"][0] assert matched["asset_block_ids"] == ["asset_1"] assert matched.get("bridge_block_ids") == ["gap_1"] + + +# === Stage 1: Cross-page figure matching tests === + + +# Task 1: caption_group_assignments same-page gate +def test_caption_group_assignments_does_not_cross_page() -> None: + from paperforge.worker.ocr import caption_group_assignments + + blocks = [ + {"block_label": "image", "page": 12, "block_bbox": [100, 100, 400, 300], "block_content": ""}, + {"block_label": "figure_title", "page": 13, "block_bbox": [100, 400, 500, 440], "block_content": "Figure 4. Test caption."}, + ] + figure_map, _ = caption_group_assignments(blocks) + assert len(figure_map) == 0, "caption_group_assignments must NOT match cross-page image/caption" + + +# Task 2: eligibility helpers +def test_strong_numbered_legend_weak_truncated_not_strong() -> None: + from paperforge.worker.ocr_figures import _is_strong_numbered_legend + + block = {"block_id": "b1", "page": 1, "role": "figure_caption", "text": "Figure 1.", "bbox": [0, 0, 100, 50], "marker_signature": {"type": "figure_number", "number": 1}, "style_family": "legend_like"} + score = {"score": 0.9, "decision": "figure_caption", "evidence": ["figure_number"]} + assert _is_strong_numbered_legend(block, caption_score=score) is False + + +def test_strong_numbered_legend_full_legend_is_strong() -> None: + from paperforge.worker.ocr_figures import _is_strong_numbered_legend + + block = {"block_id": "b1", "page": 1, "role": "figure_caption", "text": "Figure 1. Quantitative analysis of cell migration under DC electric field stimulation.", "bbox": [0, 0, 500, 50]} + score = {"score": 0.9, "decision": "figure_caption", "evidence": ["figure_number"]} + assert _is_strong_numbered_legend(block, caption_score=score) is True + + +def test_strong_numbered_legend_low_score_not_strong() -> None: + from paperforge.worker.ocr_figures import _is_strong_numbered_legend + + block = {"block_id": "b1", "page": 1, "role": "figure_caption", "text": "Figure 1. Some text.", "bbox": [0, 0, 500, 50]} + score = {"score": 0.2, "decision": "ambiguous", "evidence": ["weak"]} + assert _is_strong_numbered_legend(block, caption_score=score) is False + + +def test_strong_numbered_legend_validation_first_needs_anchor() -> None: + from paperforge.worker.ocr_figures import _is_strong_numbered_legend + + block = { + "block_id": "b1", "page": 1, "role": "unknown_structural", + "zone": "body_zone", "style_family": "legend_like", "style_family_authority": "figure_family_anchor", + "text": "Figure 1. Quantitative analysis of cell migration.", + "bbox": [0, 0, 500, 50], + "marker_signature": {"type": "figure_number", "number": 1}, + } + score = {"score": 0.9, "decision": "figure_caption", "evidence": ["figure_number"]} + assert _is_strong_numbered_legend(block, caption_score=score, anchor_supported=True) is True + assert _is_strong_numbered_legend(block, caption_score=score, anchor_supported=False, caption_text_supported=False) is False + + +def test_structurally_matchable_empty_group_not_matchable() -> None: + from paperforge.worker.ocr_figures import _is_structurally_matchable_group + + group = {"group_id": "g1", "page": 1, "asset_block_ids": [], "cluster_bbox": [0, 0, 100, 100]} + assert _is_structurally_matchable_group(group, competing_caption_pages=set()) is False + + +def test_structurally_matchable_valid_group() -> None: + from paperforge.worker.ocr_figures import _is_structurally_matchable_group + + group = {"group_id": "g1", "page": 1, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 100, 100]} + assert _is_structurally_matchable_group(group, competing_caption_pages=set()) is True + + +def test_structurally_matchable_page_assets_on_competing_page_not_matchable() -> None: + from paperforge.worker.ocr_figures import _is_structurally_matchable_group + + group = {"group_id": "g1", "page": 13, "group_type": "page_assets", "asset_block_ids": ["a1", "a2"], "cluster_bbox": [0, 0, 500, 500]} + assert _is_structurally_matchable_group(group, competing_caption_pages={13}) is False + + +# Task 3: ledger helpers +def test_page_ledger_balanced() -> None: + from paperforge.worker.ocr_figures import _build_page_ledger + + legends = [{"block_id": "c1", "page": 1, "text": "Figure 1. Caption.", "bbox": [0, 0, 100, 50]}] + groups = [{"group_id": "g1", "page": 1, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 100, 100]}] + ledger = _build_page_ledger(legends, groups) + assert ledger[1]["delta"] == 0 + assert ledger[1]["legend_count"] == 1 + assert ledger[1]["group_count"] == 1 + + +def test_page_ledger_caption_surplus() -> None: + from paperforge.worker.ocr_figures import _build_page_ledger + + legends = [ + {"block_id": "c1", "page": 1, "text": "Figure 1. First caption.", "bbox": [0, 0, 100, 50]}, + {"block_id": "c2", "page": 1, "text": "Figure 2. Second caption.", "bbox": [0, 60, 100, 110]}, + ] + groups = [{"group_id": "g1", "page": 1, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 100, 100]}] + ledger = _build_page_ledger(legends, groups) + assert ledger[1]["delta"] == 1 + + +def test_page_ledger_group_surplus() -> None: + from paperforge.worker.ocr_figures import _build_page_ledger + + legends = [{"block_id": "c1", "page": 2, "text": "Figure 1. Caption.", "bbox": [0, 0, 100, 50]}] + groups = [ + {"group_id": "g1", "page": 2, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 100, 100]}, + {"group_id": "g2", "page": 2, "asset_block_ids": ["a2"], "cluster_bbox": [0, 200, 100, 300]}, + ] + ledger = _build_page_ledger(legends, groups) + assert ledger[2]["delta"] == -1 + + +def test_residual_ledger_balanced() -> None: + from paperforge.worker.ocr_figures import _build_residual_ledger + + legends = [{"block_id": "c1", "page": 1, "text": "Figure 1. Caption.", "bbox": [0, 0, 100, 50]}] + groups = [{"group_id": "g1", "page": 1, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 100, 100]}] + ledger = _build_residual_ledger(legends, groups, competing_caption_pages=set()) + assert ledger[1]["residual_delta"] == 0 + + +def test_residual_legend_surplus_positive() -> None: + from paperforge.worker.ocr_figures import _build_residual_ledger, _residual_legend_surplus + + legends = [ + {"block_id": "c1", "page": 1, "text": "Figure 1. First.", "bbox": [0, 0, 100, 50]}, + {"block_id": "c2", "page": 1, "text": "Figure 2. Second.", "bbox": [0, 60, 100, 110]}, + ] + groups = [{"group_id": "g1", "page": 1, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 100, 100]}] + ledger = _build_residual_ledger(legends, groups, competing_caption_pages={1}) + assert ledger[1]["residual_delta"] > 0 + + +def test_residual_group_surplus_positive() -> None: + from paperforge.worker.ocr_figures import _build_residual_ledger, _residual_group_surplus + + legends = [{"block_id": "c1", "page": 2, "text": "Figure 1. Caption.", "bbox": [0, 0, 100, 50]}] + groups = [ + {"group_id": "g1", "page": 2, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 100, 100]}, + {"group_id": "g2", "page": 2, "asset_block_ids": ["a2"], "cluster_bbox": [0, 200, 100, 300]}, + ] + ledger = _build_residual_ledger(legends, groups, competing_caption_pages=set()) + assert ledger[2]["residual_delta"] < 0 + + +# Task 4: reservation +def test_reservation_reserves_top_caption_on_caption_surplus_page() -> None: + from paperforge.worker.ocr_figures import ( + _build_page_ledger, _build_residual_ledger, _reserve_cross_page_objects, + ) + + legends = [ + {"block_id": "fig4_cap", "page": 13, "text": "Figure 4. Caption for figure on page 12.", "bbox": [0, 0, 500, 50]}, + {"block_id": "fig5_cap", "page": 13, "text": "Figure 5. Caption for page 13 figure.", "bbox": [0, 100, 500, 150]}, + ] + groups = [ + {"group_id": "g1", "page": 12, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 200, 200], "media_blocks": [{"block_id": "a1", "page": 12, "bbox": [0, 0, 200, 200]}], "group_type": "single_asset", "group_evidence": ["single_asset"]}, + {"group_id": "g2", "page": 13, "asset_block_ids": ["a2"], "cluster_bbox": [0, 300, 200, 500], "media_blocks": [{"block_id": "a2", "page": 13, "bbox": [0, 300, 200, 500]}], "group_type": "single_asset", "group_evidence": ["single_asset"]}, + ] + residual = _build_residual_ledger(legends, groups, competing_caption_pages={13}) + reserved_legend_ids, reserved_group_ids = _reserve_cross_page_objects( + legends, groups, residual, competing_caption_pages={13}, sidecar_pages=set(), + ) + assert "fig4_cap" in reserved_legend_ids + assert "fig5_cap" not in reserved_legend_ids + + +def test_reservation_skips_sidecar_pages() -> None: + from paperforge.worker.ocr_figures import ( + _build_residual_ledger, _reserve_cross_page_objects, + ) + legends = [ + {"block_id": "c1", "page": 5, "text": "Figure 1. Narrow sidecar caption.", "bbox": [0, 0, 200, 50]}, + {"block_id": "c2", "page": 5, "text": "Figure 2. Another narrow caption.", "bbox": [0, 100, 200, 150]}, + ] + groups = [{"group_id": "g1", "page": 5, "asset_block_ids": ["a1"], "cluster_bbox": [0, 0, 100, 100], "group_type": "single_asset"}] + residual = _build_residual_ledger(legends, groups, competing_caption_pages={5}) + reserved_legend_ids, reserved_group_ids = _reserve_cross_page_objects( + legends, groups, residual, competing_caption_pages={5}, sidecar_pages={5}, + ) + assert len(reserved_legend_ids) == 0 + + +# Task 5: cross-page settlement +def test_cross_page_backward_settlement() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + # Page 12: 2 assets that form 1 distance_cluster (Figure 4 panels) + # Page 13: wide Figure 4 caption (top) + 2 stacked assets close together + # (Figure 5 panels, both in Fig5's caption band = 1 group) + wide Figure 5 caption (bottom) + # Ledger: page 12 = -1 (0 legends, 1 group), page 13 = +1 (2 strong legends, 1 group) + # Reservation: Fig4 caption reserved, matched backward to page 12 group + blocks = [ + {"block_id": "p12_a1", "page": 12, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 100, 300, 300]}, + {"block_id": "p12_a2", "page": 12, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 320, 300, 520]}, + {"block_id": "p13_c4", "page": 13, "role": "figure_caption", "text": "Figure 4. DC electric field stimulation results showing cell migration patterns over 48 hours.", "bbox": [100, 100, 880, 150], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 4}}, + {"block_id": "p13_f5a", "page": 13, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 300, 500, 370]}, + {"block_id": "p13_f5b", "page": 13, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 390, 500, 460]}, + {"block_id": "p13_c5", "page": 13, "role": "figure_caption", "text": "Figure 5. Quantitative analysis of migration speed under different field strengths.", "bbox": [100, 500, 880, 550], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 5}}, + ] + inv = build_figure_inventory(blocks) + fig4 = [m for m in inv["matched_figures"] if m.get("figure_number") == 4] + fig5 = [m for m in inv["matched_figures"] if m.get("figure_number") == 5] + assert len(fig4) == 1, f"Expected 1 Fig 4, got {len(fig4)}" + assert len(fig5) == 1, f"Expected 1 Fig 5, got {len(fig5)}" + assert fig4[0]["settlement_type"] in ("cross_page_backward", "cross_page_forward") + assert fig4[0]["legend_page"] == 13 + assert fig5[0]["settlement_type"] == "same_page" + + +def test_reserved_same_page_hypothesis_is_deferred_from_commit() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "p12_a1", "page": 12, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 100, 300, 300]}, + {"block_id": "p12_a2", "page": 12, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 320, 300, 520]}, + {"block_id": "p13_c4", "page": 13, "role": "figure_caption", "text": "Figure 4. DC electric field stimulation results showing cell migration patterns over 48 hours.", "bbox": [100, 100, 880, 150], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 4}}, + {"block_id": "p13_f5a", "page": 13, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 300, 500, 370]}, + {"block_id": "p13_f5b", "page": 13, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 390, 500, 460]}, + {"block_id": "p13_c5", "page": 13, "role": "figure_caption", "text": "Figure 5. Quantitative analysis of migration speed under different field strengths.", "bbox": [100, 500, 880, 550], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 5}}, + ] + + inv = build_figure_inventory(blocks) + + hypotheses = [ + h for h in inv["local_pairing_hypotheses"] if h.get("legend_block_id") == "p13_c4" + ] + assert hypotheses, "reserved caption should still produce a same-page hypothesis" + assert any(h.get("group_id") == "group_0002" for h in hypotheses) + assert any("reserved_same_page_commit_deferred" in h.get("conflicts", []) for h in hypotheses) + + fig4 = [m for m in inv["matched_figures"] if m.get("figure_number") == 4] + assert len(fig4) == 1 + assert fig4[0]["settlement_type"] in ("cross_page_backward", "cross_page_forward") + + +def test_non_reserved_same_page_pair_still_commits_normally() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "asset_1", "page": 2, "role": "figure_asset", "raw_label": "image", "bbox": [100, 100, 520, 380]}, + { + "block_id": "cap_1", + "page": 2, + "role": "figure_caption", + "text": "Figure 1. Same-page figure.", + "bbox": [100, 420, 900, 470], + "style_family": "legend_like", + "marker_signature": {"type": "figure_number", "number": 1}, + }, + ] + + inv = build_figure_inventory(blocks) + + matched = [m for m in inv["matched_figures"] if m.get("figure_number") == 1] + assert len(matched) == 1 + assert matched[0]["settlement_type"] == "same_page" + hypotheses = [ + h for h in inv["local_pairing_hypotheses"] if h.get("legend_block_id") == "cap_1" + ] + assert any(h.get("mode") == "caption_below" for h in hypotheses) + assert all("reserved_same_page_commit_deferred" not in h.get("conflicts", []) for h in hypotheses) + + +def test_cross_page_settlement_two_captions_one_group_ambiguous() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "c1", "page": 12, "role": "figure_caption", "text": "Figure 4. First caption.", "bbox": [100, 100, 500, 150]}, + {"block_id": "c2", "page": 12, "role": "figure_caption", "text": "Figure 5. Second caption.", "bbox": [100, 200, 500, 250]}, + {"block_id": "a1", "page": 12, "role": "figure_asset", "text": "", "bbox": [100, 400, 500, 700]}, + ] + inv = build_figure_inventory(blocks) + assert len(inv["matched_figures"]) <= 1 + # At most one caption should claim the single group + + +# Task 6: legacy fallback restriction — grouped asset not consumed by old fallback +def test_legacy_fallback_does_not_consume_grouped_asset() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "c1", "page": 10, "role": "figure_caption", "text": "Figure 1. Caption on page before assets.", "bbox": [100, 100, 500, 150]}, + {"block_id": "a1", "page": 11, "role": "figure_asset", "text": "", "bbox": [100, 100, 500, 400]}, + {"block_id": "a2", "page": 11, "role": "figure_asset", "text": "", "bbox": [100, 500, 500, 800]}, + ] + inv = build_figure_inventory(blocks) + fig1 = [m for m in inv["matched_figures"] if m.get("figure_number") == 1] + assert len(fig1) == 1 + assert len(fig1[0]["matched_assets"]) >= 1 + + +def test_legend_bundle_fallback_does_not_consume_grouped_assets() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "c1", "page": 10, "role": "figure_caption", "text": "Figure 1. First caption.", "bbox": [100, 100, 700, 150]}, + {"block_id": "c2", "page": 10, "role": "figure_caption", "text": "Figure 2. Second caption.", "bbox": [100, 180, 700, 230]}, + {"block_id": "c3", "page": 10, "role": "figure_caption", "text": "Figure 3. Third caption.", "bbox": [100, 260, 700, 310]}, + {"block_id": "a11_1", "page": 11, "role": "figure_asset", "text": "", "bbox": [100, 100, 420, 320]}, + {"block_id": "a11_2", "page": 11, "role": "figure_asset", "text": "", "bbox": [100, 340, 420, 560]}, + {"block_id": "a12_1", "page": 12, "role": "figure_asset", "text": "", "bbox": [100, 100, 420, 320]}, + ] + + inv = build_figure_inventory(blocks) + + legend_bundle_matches = [ + m for m in inv["matched_figures"] if m.get("settlement_type") == "legend_bundle" + ] + bundled_asset_ids = { + str(asset.get("block_id", "")) + for match in legend_bundle_matches + for asset in match.get("matched_assets", []) + } + + assert "a11_1" not in bundled_asset_ids + assert "a11_2" not in bundled_asset_ids + + +def test_legend_bundle_fallback_skips_preowned_asset_pages() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "pre_cap", "page": 9, "role": "figure_caption", "text": "Figure 1. Same-page owned figure.", "bbox": [100, 100, 700, 150], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 1}}, + {"block_id": "pre_asset", "page": 9, "role": "figure_asset", "raw_label": "image", "bbox": [100, 180, 700, 500]}, + {"block_id": "c2", "page": 10, "role": "figure_caption", "text": "Figure 2. First bundled caption.", "bbox": [100, 100, 700, 150], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 2}}, + {"block_id": "c3", "page": 10, "role": "figure_caption", "text": "Figure 3. Second bundled caption.", "bbox": [100, 200, 700, 250], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 3}}, + {"block_id": "c4", "page": 10, "role": "figure_caption", "text": "Figure 4. Third bundled caption.", "bbox": [100, 300, 700, 350], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 4}}, + {"block_id": "future_1", "page": 11, "role": "figure_asset", "raw_label": "image", "bbox": [100, 200, 600, 500]}, + {"block_id": "future_2", "page": 12, "role": "figure_asset", "raw_label": "image", "bbox": [100, 200, 600, 500]}, + ] + + inv = build_figure_inventory(blocks) + bundle_matches = [m for m in inv["matched_figures"] if m.get("settlement_type") == "legend_bundle"] + + assert all(match.get("page") != 9 for match in bundle_matches) + assert {m.get("page") for m in bundle_matches} <= {11, 12} + + +def test_legend_bundle_fallback_respects_body_interruption() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "c2", "page": 10, "role": "figure_caption", "text": "Figure 2. First bundled caption.", "bbox": [100, 100, 700, 150], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 2}}, + {"block_id": "c3", "page": 10, "role": "figure_caption", "text": "Figure 3. Second bundled caption.", "bbox": [100, 200, 700, 250], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 3}}, + {"block_id": "c4", "page": 10, "role": "figure_caption", "text": "Figure 4. Third bundled caption.", "bbox": [100, 300, 700, 350], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 4}}, + {"block_id": "body_break", "page": 11, "role": "body_paragraph", "text": "This body text interrupts bundle recovery.", "bbox": [100, 100, 1000, 240]}, + {"block_id": "future_1", "page": 12, "role": "figure_asset", "raw_label": "image", "bbox": [100, 200, 600, 500]}, + {"block_id": "future_2", "page": 13, "role": "figure_asset", "raw_label": "image", "bbox": [100, 200, 600, 500]}, + {"block_id": "future_3", "page": 14, "role": "figure_asset", "raw_label": "image", "bbox": [100, 200, 600, 500]}, + ] + + inv = build_figure_inventory(blocks) + + assert not any(m.get("settlement_type") == "legend_bundle" for m in inv["matched_figures"]) + + +def test_final_unmatched_assets_recomputed_after_late_fallback_match() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "c1", "page": 1, "role": "figure_caption", "text": "Figure 1. Caption with no same-page asset.", "bbox": [100, 100, 700, 150]}, + {"block_id": "a2", "page": 2, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [100, 120, 500, 420]}, + ] + + inv = build_figure_inventory(blocks) + + fig1 = [m for m in inv["matched_figures"] if m.get("figure_number") == 1] + assert len(fig1) == 1 + matched_asset_ids = {str(a.get("block_id", "")) for a in fig1[0].get("matched_assets", [])} + assert "a2" in matched_asset_ids + assert all(a.get("block_id") != "a2" for a in inv.get("unmatched_assets", [])) + + +# Task 7: output schema contract +def test_matched_figure_has_legend_page_and_asset_pages_and_settlement_type() -> None: + from paperforge.worker.ocr_figures import build_figure_inventory + + blocks = [ + {"block_id": "c1", "page": 1, "role": "figure_caption", "text": "Figure 1. Test caption.", "bbox": [100, 420, 500, 460]}, + {"block_id": "a1", "page": 1, "role": "figure_asset", "text": "", "bbox": [100, 50, 500, 400]}, + ] + inv = build_figure_inventory(blocks) + assert len(inv["matched_figures"]) == 1 + mf = inv["matched_figures"][0] + assert "legend_page" in mf + assert "asset_pages" in mf + assert "settlement_type" in mf + assert mf["legend_page"] == 1 + assert mf["asset_pages"] == [1] + assert mf["settlement_type"] == "same_page" + + +# Task 8: 2HEUD5P9 ownership pattern regression +def test_2heud5p9_ownership_pattern() -> None: + """Figure 4 on page 12 (assets), Fig 4 caption on page 13, Fig 5 assets+caption on page 13.""" + from paperforge.worker.ocr_figures import build_figure_inventory + + # Page 12: 2 Fig4 panels close together (1 distance_cluster) + # Page 13: wide Fig4 caption (top), 2 Fig5 panels stacked in Fig5's caption band (1 group), wide Fig5 caption (bottom) + blocks = [ + {"block_id": "p12_a1", "page": 12, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [50, 50, 300, 350]}, + {"block_id": "p12_a2", "page": 12, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [50, 370, 300, 670]}, + {"block_id": "p13_c4", "page": 13, "role": "figure_caption", "text": "Figure 4. DC electric field stimulation results showing cell migration patterns over 48 hours.", "bbox": [50, 50, 800, 100], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 4}}, + {"block_id": "p13_f5a", "page": 13, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [50, 220, 500, 340]}, + {"block_id": "p13_f5b", "page": 13, "role": "figure_asset", "raw_label": "image", "text": "", "bbox": [50, 360, 500, 470]}, + {"block_id": "p13_c5", "page": 13, "role": "figure_caption", "text": "Figure 5. Quantitative analysis of migration speed under different field strengths.", "bbox": [50, 520, 800, 570], "style_family": "legend_like", "marker_signature": {"type": "figure_number", "number": 5}}, + ] + inv = build_figure_inventory(blocks) + fig4 = [m for m in inv["matched_figures"] if m.get("figure_number") == 4] + fig5 = [m for m in inv["matched_figures"] if m.get("figure_number") == 5] + assert len(fig4) == 1, f"Expected 1 Fig 4, got {len(fig4)}" + assert len(fig5) == 1, f"Expected 1 Fig 5, got {len(fig5)}" + # Fig 4 should own page 12 assets (cross-page) or page 13 assets (same-page) + assert fig4[0]["legend_page"] == 13 + # Fig 5 should own page 13 assets + assert fig5[0]["page"] == 13 + assert fig5[0]["legend_page"] == 13 + # Orphan count should be low (ideally 0) + orphan_assets = [a for a in inv.get("unmatched_assets", []) if a.get("page") in (12, 13)] + orphan_groups = inv.get("unresolved_clusters", []) + total_orphan_assets = len(orphan_assets) + sum(len(g.get("media_block_ids", [])) for g in orphan_groups) + assert total_orphan_assets <= 2, f"Too many orphaned assets: {total_orphan_assets}" diff --git a/tests/test_ocr_render.py b/tests/test_ocr_render.py new file mode 100644 index 00000000..35524424 --- /dev/null +++ b/tests/test_ocr_render.py @@ -0,0 +1,137 @@ +from __future__ import annotations + + +def test_render_fulltext_markdown_preserves_role_heading_prefixes() -> None: + from paperforge.worker.ocr_render import render_fulltext_markdown + + structured = [ + { + "page": 1, + "role": "subsection_heading", + "text": "Methods", + "span_metadata": {"size": 12}, + "span_signature": {"bold": True}, + "block_id": "p1_b1", + }, + { + "page": 1, + "role": "body_paragraph", + "text": "Body text.", + "block_id": "p1_b2", + }, + ] + + md = render_fulltext_markdown( + structured_blocks=structured, + resolved_metadata={}, + figure_inventory={"matched_figures": [], "unmatched_assets": [], "unresolved_clusters": []}, + table_inventory={"tables": [], "unmatched_assets": []}, + page_count=1, + document_structure=None, + reader_payload={}, + ) + + assert "### Methods" in md + + +def test_render_fulltext_markdown_suppresses_cross_page_caption_on_legend_page() -> None: + from paperforge.worker.ocr_render import render_fulltext_markdown + + structured = [ + { + "page": 12, + "role": "body_paragraph", + "text": "Page 12 body.", + "block_id": "p12_b1", + }, + { + "page": 13, + "role": "figure_caption", + "text": "Figure 4. Cross-page caption.", + "block_id": 6, + }, + { + "page": 13, + "role": "body_paragraph", + "text": "Page 13 body.", + "block_id": "p13_b1", + }, + ] + + md = render_fulltext_markdown( + structured_blocks=structured, + resolved_metadata={}, + figure_inventory={"matched_figures": [], "unmatched_assets": [], "unresolved_clusters": []}, + table_inventory={"tables": [], "unmatched_assets": []}, + page_count=13, + document_structure=None, + reader_payload={ + "reader_figures": [ + { + "reader_figure_id": "figure_004_reader", + "consumed_caption_block_ids": [{"page": 13, "block_id": 6}], + "consumed_asset_block_ids": [{"page": 12, "block_id": 101}], + "caption_text": "Figure 4. Cross-page caption.", + } + ], + "consumed_caption_block_ids": [{"page": 13, "block_id": 6}], + }, + ) + + assert md.count("Figure 4. Cross-page caption.") == 1 + assert "Page 13 body." in md + + +def test_render_fulltext_markdown_does_not_double_emit_cross_page_figure_embed() -> None: + from paperforge.worker.ocr_render import render_fulltext_markdown + + structured = [ + { + "page": 50, + "role": "body_paragraph", + "text": "Page 50 body.", + "block_id": "p50_b1", + }, + { + "page": 51, + "role": "figure_caption", + "text": "Figure 24. Cross-page caption.", + "block_id": 3, + }, + { + "page": 51, + "role": "body_paragraph", + "text": "Page 51 body.", + "block_id": "p51_b1", + }, + ] + + md = render_fulltext_markdown( + structured_blocks=structured, + resolved_metadata={}, + figure_inventory={ + "matched_figures": [ + {"figure_id": "figure_024", "page": 50, "text": "Figure 24. Cross-page caption."} + ], + "unmatched_assets": [], + "unresolved_clusters": [], + }, + table_inventory={"tables": [], "unmatched_assets": []}, + page_count=51, + document_structure=None, + reader_payload={ + "reader_figures": [ + { + "reader_figure_id": "figure_024_reader", + "figure_number": 24, + "reader_status": "EXACT_MATCH", + "consumed_caption_block_ids": [{"page": 51, "block_id": 3}], + "consumed_asset_block_ids": [{"page": 50, "block_id": 101}], + "caption_text": "Figure 24. Cross-page caption.", + } + ], + "consumed_caption_block_ids": [{"page": 51, "block_id": 3}], + }, + ) + + assert md.count("![[render/figures/figure_024.md]]") == 1