File size: 17,962 Bytes
9114291
687caaa
9114291
687caaa
43d66e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd27b3
43d66e3
 
 
 
 
 
687caaa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd27b3
687caaa
 
 
 
 
 
 
 
6bd27b3
687caaa
6bd27b3
 
 
687caaa
 
6bd27b3
687caaa
 
 
b4caf58
 
 
 
 
 
 
 
 
 
 
 
6bd27b3
b4caf58
 
 
 
 
 
 
 
 
 
 
 
 
 
9114291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235d570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd27b3
235d570
 
 
 
 
6bd27b3
235d570
 
 
 
 
6bd27b3
235d570
 
 
 
 
6bd27b3
235d570
 
 
 
 
 
6bd27b3
235d570
 
 
 
 
 
 
 
6bd27b3
235d570
 
 
 
 
6bd27b3
235d570
 
 
 
 
 
 
6bd27b3
235d570
 
 
9114291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd27b3
9114291
 
 
 
 
 
 
 
 
 
 
6bd27b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca1fce6
 
 
 
 
 
 
 
 
 
 
 
 
6bd27b3
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
import time
from pathlib import Path
from astroparse_api.parse import segment_markdown, parse_pdf, _pathological_pages


# ---------------------------------------------------------------------------
# Bug A: strip_picture_text
# ---------------------------------------------------------------------------

def test_picture_text_blocks_filtered():
    from astroparse_api.parse import strip_picture_text
    lines = ["Real prose before.", "**----- Begin of picture text -----**",
             "z = 5.0 z = 4.0", "1.5", "R [kpc]",
             "**----- End of picture text -----**", "Real prose after."]
    assert strip_picture_text(lines) == ["Real prose before.", "Real prose after."]


def test_picture_text_start_end_variants():
    """Handles 'Start of picture text' / 'End of picture text' (pymupdf4llm 1.27.x)."""
    from astroparse_api.parse import strip_picture_text
    lines = [
        "Prose A.",
        "**----- Start of picture text -----**<br>",
        "axis label stuff<br>more axis<br>**----- End of picture text -----**<br>",
        "Prose B.",
    ]
    result = strip_picture_text(lines)
    assert result == ["Prose A.", "Prose B."]


def test_picture_text_multiline_block():
    """Handles multi-line blocks between start/end markers."""
    from astroparse_api.parse import strip_picture_text
    lines = [
        "Before.",
        "**----- Start of picture text -----**<br>",
        "z = 5.0 z = 4.0",
        "1.5",
        "R [kpc]",
        "**----- End of picture text -----**<br>",
        "After.",
    ]
    result = strip_picture_text(lines)
    assert result == ["Before.", "After."]


# ---------------------------------------------------------------------------
# Bug B: demarkdown
# ---------------------------------------------------------------------------

def test_demarkdown_cleans_math_debris():
    from astroparse_api.parse import demarkdown
    t = ("with a peak at _z ≈_ 4, masses between 5 _._ 7 _×_ 10[5] M _⊙_ and "
         "2 _._ 5 _×_ 10[11] M _⊙_ .")
    out = demarkdown(t)
    assert "_" not in out and "**" not in out
    assert "5.7×10⁵" in out and "2.5×10¹¹" in out and "M ⊙" in out


def test_demarkdown_bold_stripped():
    from astroparse_api.parse import demarkdown
    t = "This is **very important** and _emphasized_ text."
    out = demarkdown(t)
    assert "**" not in out and "_" not in out
    assert "very important" in out and "emphasized" in out


def test_demarkdown_backtick_stripped():
    from astroparse_api.parse import demarkdown
    t = "Use `code` here."
    out = demarkdown(t)
    assert "`" not in out
    assert "code" in out


def test_demarkdown_bracketed_exponents():
    from astroparse_api.parse import demarkdown
    assert "10⁵" in demarkdown("10[5]")
    assert "10¹¹" in demarkdown("10[11]")
    assert "10⁰" in demarkdown("10[0]")


# ---------------------------------------------------------------------------
# Bug B2: merge open-sentence continuations
# ---------------------------------------------------------------------------

def test_merge_continuation_after_open_sentence():
    # First half ends without terminal punctuation ("and"), second half starts with digit.
    # Second half is >= _MIN_PARA so len heuristic alone would NOT merge it.
    # Only the open-sentence rule (prev doesn't end with terminal punct) should merge it.
    first_half = "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi. " * 3
    first_half += "masses between five and"  # no terminal punctuation — ends open
    # second_half starts with "2" (digit, not lowercase) and is >= 200 chars
    second_half = ("2.5 something more here delta epsilon zeta eta theta "
                   "iota kappa lambda mu nu. " * 4)

    md = "## S\n\n" + first_half + "\n\n" + second_half
    paras, _ = segment_markdown(md)
    # Digit-starting continuation merged because prev para ended mid-sentence
    assert len(paras) == 1, (
        f"Expected 1 merged paragraph, got {len(paras)}: "
        + str([p['text'][:80] for p in paras])
    )

FIXTURE = Path(__file__).parent / "fixtures" / "mowla_iyer_2024.pdf"

MD = """# The Firefly Sparkle

## Introduction

""" + ("First intro paragraph. " * 15) + """

""" + ("Second intro paragraph. " * 15) + """

## Methods

""" + ("Methods paragraph here. " * 15) + """

**Figure 1.** A caption that should be dropped.

## References

[1] Someone et al. 2020
"""

def test_segment_basic():
    paras, _ = segment_markdown(MD)
    sections = [p["section"] for p in paras]
    assert sections == ["Introduction", "Introduction", "Methods"]
    assert paras[0]["firstOfSection"] and not paras[1]["firstOfSection"]
    assert paras[2]["firstOfSection"]
    assert paras[0]["id"] == "p1"
    assert all(len(p["text"]) >= 200 for p in paras)

def test_segment_drops_captions_and_references():
    paras, raw_refs = segment_markdown(MD)
    joined = " ".join(p["text"] for p in paras)
    assert "Figure 1" not in joined
    assert "Someone et al" not in joined      # not in paras
    assert "Someone et al" in raw_refs        # but captured in raw_refs

def test_parse_pdf_fixture():
    paper, _ = parse_pdf(FIXTURE.read_bytes(), "mowla_iyer_2024.pdf")
    assert 10 <= len(paper.paragraphs) <= 80
    assert paper.pages >= 10
    assert "firefly" in paper.title.lower() or len(paper.title) > 10


def test_segment_drops_author_affiliation_block():
    md = """# A Great Paper

F.RENAUD[1] _[,]_[ 2] , K. KRALJIC[1] , J. FREUNDLICH[1] , B. MAGNELLI[3] , M. BÉTHERMIN[1] , C. ACCARD[1] , D. ISMAIL[1] , E. DADDI[3] , D. ELBAZ[3] , L. CIESLA[4] , G. MARTIN[5] , Y. DUBOIS[6] , S. PEIRANI[7]
> 1 _Observatoire Astronomique de Strasbourg, Université de Strasbourg, CNRS UMR 7550, 11 rue de l'Université, F-67000 Strasbourg, France_
> 2 _University of Strasbourg Institute for Advanced Study, 5 allée du Général Rouvillois, F-67083 Strasbourg, France_

## ABSTRACT

""" + ("Star-forming galaxies spend most of their lifetimes on the main sequence of star formation. " * 4)
    paras, _ = segment_markdown(md)
    joined = " ".join(p["text"] for p in paras)
    assert "KRALJIC" not in joined and "Observatoire" not in joined
    assert any("Star-forming galaxies" in p["text"] for p in paras)


def test_clean_authors_three_plus_et_al():
    from astroparse_api.parse import _clean_authors
    raw = "F.RENAUD[1] _[,]_[ 2] , K. KRALJIC[1] , J. FREUNDLICH[1] , B. MAGNELLI[3] , M. BÉTHERMIN[1]"
    assert _clean_authors(raw) == "F.RENAUD, K. KRALJIC, J. FREUNDLICH et al."


def test_clean_authors_short_list_unchanged():
    from astroparse_api.parse import _clean_authors
    assert _clean_authors("A. One, B. Two") == "A. One, B. Two"


# ---------------------------------------------------------------------------
# Pathological-page detection unit tests
# ---------------------------------------------------------------------------

def test_pathological_pages_detects_large_stream():
    """_pathological_pages returns the index of a page whose content stream
    exceeds _MAX_PAGE_STREAM_BYTES (8 MB).  We duck-type a minimal fake doc."""
    from astroparse_api.parse import _MAX_PAGE_STREAM_BYTES

    class _FakePage:
        def __init__(self, size: int):
            self._size = size
        def read_contents(self) -> bytes:
            return b"x" * self._size

    class _FakeDoc:
        def __init__(self, sizes):
            self._pages = [_FakePage(s) for s in sizes]
        def __iter__(self):
            return iter(self._pages)

    # One small page, one huge page, one small page
    sizes = [100, _MAX_PAGE_STREAM_BYTES + 1, 200]
    doc = _FakeDoc(sizes)
    bad = _pathological_pages(doc)
    assert bad == {1}, f"Expected {{1}}, got {bad}"


def test_pathological_pages_empty_when_all_small():
    """_pathological_pages returns empty set when all pages are below threshold."""
    from astroparse_api.parse import _MAX_PAGE_STREAM_BYTES

    class _FakePage:
        def __init__(self, size: int):
            self._size = size
        def read_contents(self) -> bytes:
            return b"x" * self._size

    class _FakeDoc:
        def __init__(self, sizes):
            self._pages = [_FakePage(s) for s in sizes]
        def __iter__(self):
            return iter(self._pages)

    sizes = [100, _MAX_PAGE_STREAM_BYTES - 1, 200]
    doc = _FakeDoc(sizes)
    bad = _pathological_pages(doc)
    assert bad == set(), f"Expected empty set, got {bad}"


# ---------------------------------------------------------------------------
# Feature: parse_text — plaintext/markdown input
# ---------------------------------------------------------------------------

_MD_DOC = """# Galactic Winds in Starburst Galaxies

## Introduction

""" + ("Galaxy-scale winds driven by stellar feedback are a key mechanism for regulating " * 15) + """

""" + ("The outflow rates in starburst systems can exceed the star formation rate by factors " * 15) + """

## References

[1] Veilleux et al. 2005
[2] Rupke & Veilleux 2011
"""

def test_parse_text_title_from_heading():
    from astroparse_api.parse import parse_text
    result, _ = parse_text(_MD_DOC.encode(), "notes.md")
    assert result.title == "Galactic Winds in Starburst Galaxies"


def test_parse_text_two_paragraphs():
    from astroparse_api.parse import parse_text
    result, _ = parse_text(_MD_DOC.encode(), "notes.md")
    assert len(result.paragraphs) == 2


def test_parse_text_sections_correct():
    from astroparse_api.parse import parse_text
    result, _ = parse_text(_MD_DOC.encode(), "notes.md")
    assert all(p.section == "Introduction" for p in result.paragraphs)


def test_parse_text_references_dropped():
    from astroparse_api.parse import parse_text
    result, _ = parse_text(_MD_DOC.encode(), "notes.md")
    joined = " ".join(p.text for p in result.paragraphs)
    assert "Veilleux" not in joined


def test_parse_text_pages_zero_authors_empty():
    from astroparse_api.parse import parse_text
    result, _ = parse_text(_MD_DOC.encode(), "notes.md")
    assert result.pages == 0
    assert result.authors == ""


def test_parse_text_title_fallback_to_stem():
    from astroparse_api.parse import parse_text
    # No heading — falls back to stem of filename
    plain = ("A " + "paragraph without any heading content describing galaxies " * 20)
    result, _ = parse_text(plain.encode(), "my_draft.txt")
    assert result.title == "my_draft"


def test_parse_text_arxiv_from_filename():
    from astroparse_api.parse import parse_text
    result, _ = parse_text(_MD_DOC.encode(), "2402.08696.md")
    assert result.arxivId == "2402.08696"


def test_parse_text_utf8_replace_on_bad_bytes():
    from astroparse_api.parse import parse_text
    # Inject a Latin-1 byte that isn't valid UTF-8, surrounded by valid UTF-8
    bad = b"# My Title\n\n" + b"Good text here. " * 20 + b"\xff" + b" more text. " * 10
    result, _ = parse_text(bad, "test.md")
    assert result.title == "My Title"


def test_pathological_pages_handles_read_contents_exception():
    """_pathological_pages silently skips pages where read_contents raises."""
    class _FaultyPage:
        def read_contents(self):
            raise RuntimeError("PDF error")

    class _FakeDoc:
        def __iter__(self):
            return iter([_FaultyPage()])

    bad = _pathological_pages(_FakeDoc())
    assert bad == set()


# ---------------------------------------------------------------------------
# Integration test: real pathological PDF
# ---------------------------------------------------------------------------

def test_parse_pdf_handles_vector_heavy_pages():
    """parse_pdf completes within 60s on the JWST paper (page 3 has 65 MB
    vector drawings that previously caused pymupdf4llm to run for 400+s)."""
    pdf = Path(__file__).parent.parent.parent / "test_papers" / "s41586-024-08293-0.pdf"
    if not pdf.exists():
        import pytest
        pytest.skip("test paper not present")
    t0 = time.time()
    paper, _ = parse_pdf(pdf.read_bytes(), pdf.name)
    elapsed = time.time() - t0
    assert elapsed < 60, f"parse_pdf took {elapsed:.1f}s — expected < 60s"
    assert len(paper.paragraphs) >= 30, (
        f"Expected >= 30 paragraphs, got {len(paper.paragraphs)}"
    )
    joined = " ".join(p.text for p in paper.paragraphs)
    assert (
        "Firefly" in paper.title
        or "Firefly" in joined
        or len(joined) > 10000
    ), "Expected Firefly Sparkle content or substantial text"


# --- Fix 0.1: pending-head buffer saves section openers ---
def test_pending_buffer_saves_section_opener():
    """A short block at a section boundary should be kept, not dropped."""
    short_opener = "We begin with a key observation here."  # < 200 chars
    long_body = "The stellar mass function at high redshift is uncertain. " * 6
    md = f"## Section A\n\n{short_opener}\n\n{long_body}"
    paras, _ = segment_markdown(md)
    joined = " ".join(p["text"] for p in paras)
    assert "key observation" in joined, "short section opener was dropped (pending buffer failed)"

# --- Fix 0.2: skip-and-resume keeps appendix content ---
def test_references_captured_and_appendix_preserved():
    """Content after the References heading, in an Appendix, should appear in paras."""
    body = "Star-forming galaxies remain on the main sequence for extended periods. " * 5
    ref_body = "[1] Smith et al. 2020 ApJ 900 1"
    appendix_body = "This appendix derives the mass-to-light ratio correction. " * 5
    md = (
        f"## Introduction\n\n{body}\n\n"
        f"## References\n\n{ref_body}\n\n"
        f"## Appendix A\n\n{appendix_body}"
    )
    paras, raw_refs = segment_markdown(md)
    joined = " ".join(p["text"] for p in paras)
    assert "mass-to-light ratio" in joined, "appendix content was discarded after References"
    assert "Smith et al." in raw_refs, "raw_refs should contain references section text"

# --- Fix 0.3: headings chain attached to paragraphs ---
def test_headings_chain_on_paragraph():
    """First paragraph under a heading should carry the heading in its headings list."""
    body = "Galaxy evolution proceeds through mergers and secular processes. " * 5
    md = f"## Methods\n\n{body}"
    paras, _ = segment_markdown(md)
    assert len(paras) == 1
    assert "Methods" in paras[0]["headings"]

def test_headings_chain_consecutive_headings():
    """Two consecutive headings both appear in the next paragraph's headings list."""
    body = "The SFR surface density is measured from UV continuum emission. " * 5
    md = f"## Results\n\n### 3.1 Star Formation\n\n{body}"
    paras, _ = segment_markdown(md)
    assert len(paras) == 1
    assert paras[0]["headings"] == ["Results", "3.1 Star Formation"]

# --- Fix 0.4: heading hygiene ---
def test_heading_hygiene_filters_page_numbers():
    """A heading shaped like '456 K. G. Iyer et al.' should be discarded."""
    body = "Outflow rates exceed the star formation rate in starburst systems. " * 5
    md = (
        "## Introduction\n\n" + body +
        "\n\n## 456 K. G. Iyer et al.\n\n" +
        "## Results\n\n" + body
    )
    paras, _ = segment_markdown(md)
    sections = [p["section"] for p in paras]
    assert "456 K. G. Iyer et al." not in sections

# --- Fix 0.5: caption gate allows figure-referencing prose ---
def test_caption_gate_allows_figure_prose():
    """'Figure 4 shows that the fraction...' is prose and must not be dropped."""
    prose = ("Figure 4 shows that the fraction of star-forming galaxies decreases "
             "sharply above a stellar mass of 10^10.5 solar masses, consistent with "
             "quenching operating preferentially at the high-mass end of the distribution "
             "and in dense environments where AGN feedback is more effective.")
    md = "## Results\n\n" + prose
    paras, _ = segment_markdown(md)
    assert any("fraction of star-forming galaxies" in p["text"] for p in paras), \
        "caption gate dropped figure-referencing prose"

def test_caption_gate_still_drops_real_captions():
    """'Figure 4. The fraction...' is a caption and should be dropped."""
    caption = "Figure 4. The fraction of star-forming galaxies as a function of stellar mass."
    body = "Galaxy stellar mass functions have been measured across cosmic time. " * 5
    md = f"## Results\n\n{body}\n\n{caption}"
    paras, _ = segment_markdown(md)
    joined = " ".join(p["text"] for p in paras)
    assert "The fraction of star-forming galaxies as a function" not in joined

def test_heading_not_leaked_from_empty_section():
    """Headings from a section with no prose don't appear in the next section's paragraph."""
    body = "The stellar mass function evolves significantly from z=2 to the present day. " * 5
    # Ghost section has no content (heading immediately followed by another heading)
    md = "## Ghost Section\n\n## Real Section\n\n" + body
    paras, _ = segment_markdown(md)
    assert len(paras) == 1
    # "Ghost Section" must NOT appear in the paragraph's headings
    assert "Ghost Section" not in paras[0]["headings"], \
        f"Ghost Section leaked into headings: {paras[0]['headings']}"
    assert "Real Section" in paras[0]["headings"]


# --- Fix 0.6: length measured post-demarkdown ---
def test_length_measured_post_demarkdown():
    """A paragraph with heavy markdown markup that is still >= 200 chars after demarkdown must survive."""
    # Raw text has lots of emphasis markers; after demarkdown the content is still >= 200 chars.
    core = ("_z_ ≈ 3 sources show Lyman-alpha emission tracing the neutral hydrogen distribution "
            "in high-redshift galaxies at cosmic noon. "
            "The _flux_ calibration introduces _systematic_ uncertainties of order 10 per cent, "
            "which must be accounted for in the _stellar mass_ estimates.")
    md = "## Discussion\n\n" + core
    paras, _ = segment_markdown(md)
    assert len(paras) == 1, "post-demarkdown paragraph was dropped by length gate"