Skip to content

Commit 4c6c0e0

Browse files
committed
Add approach A handoff runbook and persist banner/corrector diagnostics
1 parent 49cb0bc commit 4c6c0e0

3 files changed

Lines changed: 312 additions & 0 deletions

File tree

calibration/APPROACH_A_HANDOFF.md

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import sys, glob, os
2+
sys.path.insert(0, "/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/poster2json")
3+
from poster2json import extract as E
4+
E.log = lambda *a, **k: None
5+
A = "/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/json_schema/manual_poster_annotation"
6+
CASES = {
7+
"5128504": A + "/5128504",
8+
"8228476": A + "/8228476",
9+
"isporeu2023ee359130949-pdf": A + "/isporeu2023ee359130949-pdf",
10+
"gasimova": "/storage/poster-work", # gasimova.pdf lives here
11+
}
12+
for pid, d in CASES.items():
13+
pdf = glob.glob(d + "/*.pdf")
14+
pdf = [p for p in pdf if pid.split("ee")[0] in os.path.basename(p) or pid == "gasimova"]
15+
if not pdf:
16+
print(pid, "NO PDF")
17+
continue
18+
gen = E.extract_text_with_pdfplumber(pdf[0]) or ""
19+
print("=" * 74)
20+
print(pid)
21+
for i, ln in enumerate([l for l in gen.splitlines() if l.strip()][:9]):
22+
print(f" {i}| {ln[:118]!r}")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import sys, json, glob, os, re
2+
sys.path.insert(0, "/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/poster2json")
3+
from poster2json import extract as E
4+
A = "/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/json_schema/manual_poster_annotation"
5+
IDS = ["15963941", "42", "4519718", "4560930", "4564017", "6724771", "isporeu2023ee359130949-pdf"]
6+
7+
8+
def trace(pid, raw, gt):
9+
fams = []
10+
for c in gt:
11+
nm = c["name"]
12+
fams.append(nm.split(",")[0].strip() if "," in nm else nm)
13+
print("=" * 72)
14+
print(pid, "| authors:", len(gt), "| families:", fams[:4], "...")
15+
region = E._banner_region(raw, fams[0])
16+
if not region:
17+
print(" BAIL: no banner_region for", repr(fams[0]))
18+
return
19+
region = region.translate(E._SUP_TRANS)
20+
print(" region[:240]:", repr(region[:240]))
21+
# affiliation candidates
22+
cands = [(int(m.group(1)), m.start(1), m.end()) for m in E._AFFIL_MARK.finditer(region)]
23+
print(" _AFFIL_MARK cands nums:", [c[0] for c in cands])
24+
real = []
25+
for i, (num, ms, me) in enumerate(cands):
26+
seg_end = cands[i + 1][1] if i + 1 < len(cands) else len(region)
27+
seg = region[me:seg_end][:280]
28+
kw = bool(E._INSTITUTION_KW.search(seg))
29+
if kw:
30+
real.append((num, ms, me))
31+
else:
32+
print(f" marker {num}: NO institution-kw in {seg[:70]!r}")
33+
print(" real (kw-bearing) nums:", [r[0] for r in real])
34+
parsed = E._parse_affiliation_block(region)
35+
if parsed is None:
36+
print(" BAIL: _parse_affiliation_block -> None")
37+
# detect ran-into-body
38+
for num, ms, me in real:
39+
end = len(region)
40+
txt = region[me:end][:200]
41+
if E._affiliation_ran_into_body(txt):
42+
why = ("len>180" if len(txt) > E._AFFIL_MAX_LEN else
43+
"PROSE_RUN" if E._PROSE_RUN.search(txt) else "CAPS_RUN")
44+
print(f" ran_into_body[{num}] ({why}): {txt[:90]!r}")
45+
return
46+
amap, bs = parsed
47+
print(" amap keys:", sorted(amap.keys()))
48+
ar = region[:bs]
49+
bad = []
50+
for fam in fams:
51+
marks = E._author_marker_nums(ar, fam)
52+
if not marks or any(n not in amap for n in marks):
53+
bad.append((fam, marks))
54+
if bad:
55+
print(" BAIL: authors without resolvable marker:", bad[:6])
56+
else:
57+
print(" WOULD FIRE OK")
58+
59+
60+
for pid in IDS:
61+
d = os.path.join(A, pid)
62+
raw = open(glob.glob(d + "/*_raw.md")[0], encoding="utf-8").read()
63+
ann = json.load(open(os.path.join(d, pid + ".json"), encoding="utf-8"))
64+
gt = [c for c in ann["creators"] if c.get("name")]
65+
trace(pid, raw, gt)

0 commit comments

Comments
 (0)