|
| 1 | +# Approach A handoff: xy_cut banner reading order |
| 2 | + |
| 3 | +Self-contained handoff so a fresh chat (or a person) can pick up the xy_cut |
| 4 | +reading-order fix with no prior context. Read `TRACK_B_PLAN.md` in this folder |
| 5 | +alongside this; that has the full Track B framing, this is the operational |
| 6 | +runbook for approach A specifically. |
| 7 | + |
| 8 | +## TL;DR |
| 9 | + |
| 10 | +The affiliation corrector (in `poster2json/extract.py`) is now correct: given |
| 11 | +banner text in the right reading order it reassigns author affiliations |
| 12 | +deterministically, and it passes on 6 of 13 numbered posters end-to-end plus 9 |
| 13 | +of 13 on ideal-order text. Three posters (gasimova, isporeu2023, 8228476) fail |
| 14 | +only because `xy_cut.py` scrambles the banner during text extraction. Approach A |
| 15 | +fixes that reading order. It touches every poster, so regression control is the |
| 16 | +whole game. |
| 17 | + |
| 18 | +## Hard constraints (do not violate) |
| 19 | + |
| 20 | +- Do not merge to main until the repo owner signs off. Work only on |
| 21 | + `feature/xy-cut-calibration`. |
| 22 | +- Never add Claude/AI attribution to commits or PRs. Commit as the user, no |
| 23 | + Co-Authored-By, no "Generated with" footer. Use |
| 24 | + `git -c commit.gpgsign=false commit`. |
| 25 | +- Precision over coverage: it is better to leave a poster's reading order |
| 26 | + slightly off than to regress a currently-passing poster. Every change is |
| 27 | + measured against the guard baselines before committing. |
| 28 | +- No em/en dashes or special characters in prose you write. |
| 29 | + |
| 30 | +## Environment and access |
| 31 | + |
| 32 | +- Processing host is hpcf, reached over Tailscale: |
| 33 | + `ssh -o StrictHostKeyChecking=accept-new joneill@100.115.159.103` |
| 34 | + (from home use the LAN IP 192.168.1.223 if tailscale is not up). |
| 35 | +- Python env: `~/myenv/bin/python` (has torch, transformers, pdfplumber, |
| 36 | + PyMuPDF 1.27, rouge_score). CPU is fine; the reading-order work needs no GPU. |
| 37 | +- Repo path on hpcf (this is a Nextcloud vault working copy, the git repo lives |
| 38 | + here): |
| 39 | + `/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/poster2json` |
| 40 | + Call it `$P`. `cd $P`. |
| 41 | +- Editing pattern that has worked: `scp` a file down, edit locally, `scp` back, |
| 42 | + then run remotely. `/tmp` on hpcf is fine for throwaway diagnostic scripts. |
| 43 | +- The 20-poster annotation corpus: |
| 44 | + `/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/json_schema/manual_poster_annotation/<id>/` |
| 45 | + each has `<id>.pdf`, `<id>_raw.md` (human reading-order ground truth), |
| 46 | + `<id>.json` (schema annotation, author->affiliation ground truth). |
| 47 | +- gasimova is a 21st out-of-sample check: pdf, `gasimova_clean_raw.md`, and |
| 48 | + `gasimova_annotation.json` live in `/storage/poster-work/`. |
| 49 | + |
| 50 | +## Repo and branch state |
| 51 | + |
| 52 | +- Branch `feature/xy-cut-calibration`, pushed to origin. Commits so far: |
| 53 | + calibration workbench, affiliation-corrector banner sub-metric + per-field |
| 54 | + length-normalized rougeL, Track A corrector robustness (5 commits), Track B |
| 55 | + plan, approach C fallback. Track A and C are DONE. Approach A is not started. |
| 56 | +- `xy_cut.py` is unchanged from main so far. It is your only real edit target |
| 57 | + for approach A (plus possibly one new module constant). |
| 58 | + |
| 59 | +## The calibration harness (your measurement tool) |
| 60 | + |
| 61 | +`$P/calibration/reading_order_eval.py`. CPU-only, ~30 to 60s, no LLM. |
| 62 | + |
| 63 | + cd $P/calibration |
| 64 | + ~/myenv/bin/python reading_order_eval.py # scoreboard |
| 65 | + ~/myenv/bin/python reading_order_eval.py --details # + per-field rougeL |
| 66 | + ~/myenv/bin/python reading_order_eval.py --sweep MIN_GAP_AREA 2.0,2.5,3.0,3.5,4.0 |
| 67 | + ~/myenv/bin/python reading_order_eval.py --save baselines/try_X.json |
| 68 | + |
| 69 | +It runs `extract_text_with_pdfplumber` (which drives xy_cut) on each poster and |
| 70 | +reports, per poster: |
| 71 | +- `w` word capture vs `_raw.md` |
| 72 | +- `rGlob` whole-document rougeL (banner drowns in it; do not optimize this) |
| 73 | +- `rField` equal-weight per-field rougeL, length-normalized. THIS is the |
| 74 | + reading-order headline. Title, authors+affiliations, and each section each |
| 75 | + count once regardless of length, so banner damage shows up here. |
| 76 | +- `scheme` numbered / single / n/a |
| 77 | +- `refOK` corrector correctness on the ideal `_raw.md` (already 1.00 for the 3 |
| 78 | + targets, so their logic is fine) |
| 79 | +- `genOK` corrector correctness on the xy_cut output (this is what approach A |
| 80 | + must drive to 1.00 for the 3 targets) |
| 81 | +- `status` PASS / ORDER-GAP / LOGIC-GAP / single(n/a) / n/a(<2 authors) |
| 82 | + |
| 83 | +The metric that proves approach A worked is genOK going to 1.00 on the three |
| 84 | +ORDER-GAP posters without any other poster regressing. |
| 85 | + |
| 86 | +## Guard baselines and success criteria |
| 87 | + |
| 88 | +Reference file: `baselines/after_track_a.json` (also `before_track_b.json`, |
| 89 | +identical scoreboard). Current corpus averages: w=0.976, rGlobal=0.835, |
| 90 | +rField=0.727. AFFIL: 6/13 end-to-end, 9/13 logic-OK, 14/21 acceptable. |
| 91 | + |
| 92 | +Approach A succeeds when: |
| 93 | +1. genOK reaches 1.00 for gasimova and isporeu2023 (8228476 is RTL, see |
| 94 | + approach D, treat separately and do not block on it). |
| 95 | +2. No poster currently at status PASS regresses. |
| 96 | +3. Corpus `rField >= 0.727` and `rGlobal >= 0.835` (ideally rField rises). |
| 97 | +4. Full test suite green: `cd $P && ~/myenv/bin/python -m pytest tests/ |
| 98 | + poster2json/tests/ -q` (currently 260 passed, 1 skipped; takes ~70s, give it |
| 99 | + a generous timeout). |
| 100 | + |
| 101 | +## The three target posters (precise failure modes) |
| 102 | + |
| 103 | +Inspect live with `calibration/diagnostics/trace_banner_order.py` (prints the |
| 104 | +first 9 non-empty xy_cut lines for the targets). Current state: |
| 105 | + |
| 106 | +**gasimova** (the canonical case). xy_cut emits: |
| 107 | +``` |
| 108 | +## Clinical Dataset Structure: ... Datasets |
| 109 | +## Patel HUB |
| 110 | +1 , Sanjay Soundarajan 1 , Nayoon Gim 2,3,4 , ... , Gasimova Bhavesh |
| 111 | +Aydan |
| 112 | +## Background |
| 113 | +## Results |
| 114 | +1 FAIR Data Innovations Hub, ... 5 John F. Hardesty ... |
| 115 | +``` |
| 116 | +The full-width top banner band is vsplit into body columns: the byline is |
| 117 | +fragmented and reordered ("Patel HUB", "Gasimova Bhavesh", "Aydan" split off), |
| 118 | +and the two body section headers "## Background" / "## Results" (tops of the left |
| 119 | +and right columns) are emitted between the byline and the affiliation legend. |
| 120 | +The lead author "Aydan Gasimova" is split from her `1` marker (which is stranded |
| 121 | +at the very start of the byline line). Fixing the reading order so the banner |
| 122 | +reads title, then full byline, then full legend, top to bottom across the width, |
| 123 | +recovers this poster (the corrector already handles that text, verified). |
| 124 | + |
| 125 | +**isporeu2023**. The byline markers are largely lost/scrambled in extraction: |
| 126 | +only the ORCID-list names survive ("ORCID iDs: Ivanyi P, https://..."), and one |
| 127 | +author (Colombo) is missing from the extracted text entirely. This is a harder |
| 128 | +extraction loss than gasimova; approach A may or may not recover it depending on |
| 129 | +how the banner is re-grouped. Do gasimova first and re-check isporeu2023 after. |
| 130 | + |
| 131 | +**8228476**. Right-to-left (Hebrew). Authors are one per line with emails, and |
| 132 | +the affiliation legend arrives with its leading markers detached from the |
| 133 | +institution names. Needs bidi-aware handling (approach D), not just banner |
| 134 | +promotion. Do not let it block gasimova/isporeu2023. |
| 135 | + |
| 136 | +## xy_cut.py architecture (your edit target) |
| 137 | + |
| 138 | +File: `$P/poster2json/xy_cut.py`, ~295 lines. A Python port of xpdf's recursive |
| 139 | +XY-cut. Entry point `chars_to_reading_order(raw_chars, page_width, page_height)`: |
| 140 | +``` |
| 141 | +tree = split_block(chars) # recursive largest-gap split |
| 142 | +tree = _promote_spanning_leaves(tree, page_width) # pull wide leaves out of vsplits |
| 143 | +tree = _merge_bottom_region(tree, page_height) # bottom band reads across width |
| 144 | +return traverse(tree) # in-order -> reading-order lines |
| 145 | +``` |
| 146 | + |
| 147 | +Tunable module constants (top of file): `MIN_GAP_AREA=3.0`, |
| 148 | +`SPLIT_GAP_SLACK=0.2`, `MIN_CHUNK_WIDTH=2.0`, `MIN_GAP_SIZE=0.2`, |
| 149 | +`BASELINE_RANGE=0.5`, `DESCENT_ADJUST=0.35`. The harness sweeps these by |
| 150 | +monkeypatch (`--sweep`), so you can explore without editing the file. |
| 151 | + |
| 152 | +Two existing functions are the templates for approach A: |
| 153 | +- `_promote_spanning_leaves(block, page_width)`: finds leaves wider than |
| 154 | + `0.7 * page_width` nested inside a vsplit and re-wraps them as hsplit siblings |
| 155 | + so a spanning block is not trapped in one column. |
| 156 | +- `_merge_bottom_region(block, page_height)`: for a top-level vsplit starting |
| 157 | + below `page_height * 0.65`, reorders its children by y so the bottom band |
| 158 | + (Conclusion, References) reads across the full width instead of column by |
| 159 | + column. |
| 160 | + |
| 161 | +## Approach A: the banner is the missing TOP analog |
| 162 | + |
| 163 | +Root cause: the page vsplits into columns first (the column gutters are the |
| 164 | +biggest gaps and reach up near the top), so the top full-width banner band gets |
| 165 | +partitioned into columns and interleaved with the tops of the body sections. |
| 166 | + |
| 167 | +Primary idea: add a `_merge_top_band` (mirror of `_merge_bottom_region`) that, |
| 168 | +for a top-level vsplit whose bbox starts in the top band |
| 169 | +(`bbox[1] < page_height * TOP_BAND`, start TOP_BAND ~0.22), reorders the spanning |
| 170 | +rows of the banner (title, byline, legend) to read top to bottom across the full |
| 171 | +width before the narrower column children. Add `TOP_BAND` as a new module |
| 172 | +constant so it is sweepable. Reuse the wide-leaf test from |
| 173 | +`_promote_spanning_leaves` (`> 0.7 * page_width`). |
| 174 | + |
| 175 | +Things to work out empirically (this is the "number of tries" part): |
| 176 | +- Whether to reorder inside the existing top-level vsplit, or to hsplit the |
| 177 | + page into (top band | body) before the column vsplit. The second is cleaner |
| 178 | + but a bigger change to the split order. |
| 179 | +- How the byline, which is one wide line, is being fragmented. It may need the |
| 180 | + line clustering (`_cluster_lines`, `BASELINE_RANGE`) or the single-line guard |
| 181 | + (`block_h < 1.5 * avg_fs` in `split_block`) adjusted so the byline is kept as |
| 182 | + one wide leaf that then gets promoted. Lift that `1.5` and the `0.7`/`0.65` |
| 183 | + literals to module constants if you want to sweep them. |
| 184 | +- Verify the fix does not merge the banner into the first body section (watch |
| 185 | + the passing posters' rField). |
| 186 | + |
| 187 | +## Iteration protocol (follow this every change) |
| 188 | + |
| 189 | +1. `~/myenv/bin/python reading_order_eval.py --save baselines/before_try.json` |
| 190 | +2. Make ONE change (a new function, or one swept constant). |
| 191 | +3. `~/myenv/bin/python reading_order_eval.py` and eyeball: did genOK on the 3 |
| 192 | + targets improve, and did any per-poster w / rGlob / rField drop vs |
| 193 | + `after_track_a.json`? Any PASS -> not-PASS is a regression: revert or narrow. |
| 194 | +4. Spot-check raw banner text with |
| 195 | + `~/myenv/bin/python calibration/diagnostics/trace_banner_order.py` on the 3 |
| 196 | + targets AND 3 passing posters (5128504, 4564017, 10890106) to confirm you did |
| 197 | + not scramble a working banner. |
| 198 | +5. If clean, run the full pytest suite, then commit on the feature branch. |
| 199 | +6. Keep changes small and independently measured. A broad change with a |
| 200 | + net-positive average can still silently break individual posters; the |
| 201 | + per-field, per-poster table is there to catch that. |
| 202 | + |
| 203 | +## Diagnostics available in this folder |
| 204 | + |
| 205 | +- `diagnostics/trace_banner_order.py`: prints the first 9 non-empty xy_cut lines |
| 206 | + for the ORDER-GAP posters (edit the CASES dict for others). Your main eyeball |
| 207 | + tool for banner reading order. |
| 208 | +- `diagnostics/trace_corrector_bail.py`: for a list of poster ids, prints where |
| 209 | + the affiliation corrector bails (banner region, legend parse, per-author |
| 210 | + marker resolution). Useful to confirm a reading-order fix actually lets the |
| 211 | + corrector fire. |
| 212 | +- Quick corrector check on one poster's xy_cut output: import |
| 213 | + `poster2json.extract as E`, `gen = E.extract_text_with_pdfplumber(pdf)`, seed |
| 214 | + creators from the annotation json, call |
| 215 | + `E._correct_affiliations_from_superscripts(result, gen)`, check for the |
| 216 | + "Affiliations reassigned" note in `result["_validation"]`. |
| 217 | + |
| 218 | +## Non-goals for approach A |
| 219 | + |
| 220 | +- 8228476 (RTL) is approach D, separate effort. `_add_bidi_markers` already |
| 221 | + exists in extract.py as a starting point. |
| 222 | +- The LOGIC-GAP tail (6724771 abstract bleed, 42 author-count mismatch, 4519718 |
| 223 | + markerless last author, 4560930 partial) is corrector-side and out of scope |
| 224 | + for reading order. |
| 225 | +- Do not chase rGlobal; optimize genOK on the targets and rField, guard the rest. |
0 commit comments