Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
888 changes: 888 additions & 0 deletions docs/test-matrix.json

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions src/evidence_chunker/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ def find_section_header(parsed: "ParsedDoc", table_page: int, table_top_y: float
return same_above[0][1]

if prev_pages:
# 가장 가까운 이전 페이지 중, 그 페이지에서 가장 나중에 등장한
# (TOPLEFT cy 최솟값) 헤더.
prev_pages.sort(key=lambda x: (x[0], -x[1]))
# 가장 가까운 이전 페이지(page_no 최대)를 고르고, 그 페이지에서 시각적으로
# 가장 아래 = 가장 나중에 등장한 헤더를 고른다. TOPLEFT에서는 cy가 클수록
# 페이지 아래쪽이므로, 오름차순 정렬 후 마지막 원소가 (최대 page_no, 최대 cy)다.
prev_pages.sort(key=lambda x: (x[0], x[1]))
return prev_pages[-1][2]

return None
Expand Down
2 changes: 2 additions & 0 deletions tests/specification/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Submitted specification acceptance tests (QA-01 through QA-44)."""

59 changes: 59 additions & 0 deletions tests/specification/_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Small deterministic inputs. Expected values must not come from tested outputs."""
from html import escape
from evidence_chunker.parser.base import BBox, BlockLabel, TextBlock, TableBlock, ParsedDoc

EU_KEYS = {
"chunk_id", "eu_id", "doc_id", "page_no", "page_span", "table_index",
"section_header", "caption_text", "bbox", "is_split", "split_index",
"total_splits", "caption_confidence", "retrieval_text",
}
TEXT_KEYS = {"chunk_id", "eu_id", "doc_id", "page_no", "source"}

def cell(text, row, col, *, column_header=False, row_header=False, row_end=None, col_end=None):
return dict(text=text, column_header=column_header, row_header=row_header,
start_row_offset_idx=row, end_row_offset_idx=row_end or row+1,
start_col_offset_idx=col, end_col_offset_idx=col_end or col+1)

def text(index, value, *, label="text", page=1, center=300, left=20, right=280):
return TextBlock(index, value, BlockLabel(label), page,
BBox(left, center-5, right, center+5))

def table(index=0, *, page=1, rows=2, top=400, bottom=500, prefix="R"):
cells=[cell("Name",0,0,column_header=True),cell("Value",0,1,column_header=True)]
body=[]
for r in range(1, rows+1):
name=f"{prefix}{r:02d}"
value=f"value-{r:02d} alpha beta gamma delta"
cells += [cell(name,r,0,row_header=True),cell(value,r,1)]
body.append(f"<tr><td>{escape(name)}</td><td>{escape(value)}</td></tr>")
html="<table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody>"+"".join(body)+"</tbody></table>"
return TableBlock(index,page,BBox(20,top,280,bottom),cells,rows+1,2,html)

def parsed(tables=None, texts=None):
return ParsedDoc(texts or [],tables or [],set(),{p:(600.0,1000.0) for p in (1,2,3)})

def expected_sentences(rows, prefix="R"):
return [f"{prefix}{r:02d} | Value: value-{r:02d} alpha beta gamma delta" for r in range(1,rows+1)]

class StaticParser:
def __init__(self, document):
self.document=document
self.calls=[]
def parse(self,path):
self.calls.append(path)
return self.document

def raw_unit(rows=24):
from evidence_chunker.chunker import build_evidence_units
t=table(rows=rows)
t.caption_refs=[1]
t.footnote_refs=[3]
ts=[
text(0,"Results",label="section_header",center=100),
text(1,"Table 1: Measurement",label="caption",center=380),
text(2,"Measurements use the same evaluation conditions.",center=350),
text(3,"Only validated measurements are reported.",label="other",center=530),
text(4,"Additional observations follow the measurement.",center=550),
]
return build_evidence_units(parsed([t],ts),doc_id="fixture")[0]

56 changes: 56 additions & 0 deletions tests/specification/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Opt-in external tests. Known specification gaps remain real failures."""
from pathlib import Path
import hashlib
import json
import pytest

def pytest_addoption(parser):
group = parser.getgroup("submitted-specification")
group.addoption("--run-spec-integration", action="store_true",
help="Run real PDF and embedding model tests.")
group.addoption("--run-spec-install", action="store_true",
help="Run network-dependent clean-venv installation test.")
group.addoption("--spec-install-target", default="evidence-chunker",
help="pip install target; default checks submitted PyPI installation.")
group.addoption("--spec-pdf", default="data/pdfs/attention.pdf")
group.addoption("--spec-oracle", default="tests/specification/fixtures/attention.expected.json")

def pytest_configure(config):
config.addinivalue_line("markers", "spec(id): submitted specification row ID")
config.addinivalue_line("markers", "spec_gap: expected requirement not met by baseline code; NOT xfail")
config.addinivalue_line("markers", "spec_integration: real PDF or embedding model required")
config.addinivalue_line("markers", "spec_install: clean environment installation with network")

def pytest_collection_modifyitems(config, items):
for item in items:
if "spec_integration" in item.keywords and not config.getoption("--run-spec-integration"):
item.add_marker(pytest.mark.skip(reason="Opt in with --run-spec-integration; not acceptance PASS"))
if "spec_install" in item.keywords and not config.getoption("--run-spec-install"):
item.add_marker(pytest.mark.skip(reason="Opt in with --run-spec-install; not acceptance PASS"))

@pytest.fixture(scope="session")
def spec_pdf_bundle(request):
from evidence_chunker import EvidenceChunker
from evidence_chunker.parser.docling import DoclingParser
path = Path(request.config.getoption("--spec-pdf")).resolve()
oracle_path = Path(request.config.getoption("--spec-oracle")).resolve()
if not path.is_file() or not oracle_path.is_file():
pytest.fail("Required PDF/oracle missing. Prepare --spec-pdf and --spec-oracle; see fixtures/README.md.")
oracle = json.loads(oracle_path.read_text(encoding="utf-8"))
assert oracle.get("pdf_sha256") == hashlib.sha256(path.read_bytes()).hexdigest(), "PDF hash mismatch"
assert isinstance(oracle.get("table_count"), int) and oracle["table_count"] > 0
assert isinstance(oracle.get("page_count"), int) and oracle["page_count"] > 0
assert len(oracle.get("tables", [])) == oracle["table_count"], "Human-reviewed table oracle incomplete"
assert [t["index"] for t in oracle["tables"]] == list(range(oracle["table_count"]))
for t in oracle["tables"]:
assert isinstance(t.get("page_no"), int) and 1 <= t["page_no"] <= oracle["page_count"]
assert t.get("cell_texts"), "Fill independently reviewed cell_texts for every table"
if t.get("expect_eu", True):
assert t.get("data_rows"), "Fill independently reviewed non-header data rows"
assert isinstance(t.get("expect_split"), bool)
assert "section_header" in t and "required_text" in t and "absent_fields" in t
chunker = EvidenceChunker()
doc = chunker._parse(str(path))
parsed = DoclingParser().from_doc(doc)
return path, oracle, chunker, doc, parsed

20 changes: 20 additions & 0 deletions tests/specification/fixtures/attention.expected.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"_instructions": "Copy to attention.expected.json and fill by inspecting the exact PDF; never generate answers from the tested output.",
"pdf_sha256": null,
"page_count": null,
"table_count": null,
"tables": [
{
"index": 0,
"page_no": null,
"cell_texts": [],
"expect_eu": true,
"expect_split": false,
"section_header": null,
"data_rows": [],
"required_text": [],
"absent_fields": []
}
]
}

138 changes: 138 additions & 0 deletions tests/specification/test_spec_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from collections import Counter
from types import SimpleNamespace
import pytest
from evidence_chunker import EvidenceChunker, EvidenceUnit
from evidence_chunker.chunker import build_evidence_units
from evidence_chunker.parser.docling import DoclingParser
from evidence_chunker.export import filter_consumed_paragraphs
from ._data import EU_KEYS, StaticParser, parsed, table, text, expected_sentences

@pytest.mark.spec("QA-02")
def test_qa02_default_constructor():
c=EvidenceChunker()
assert c.bbox_threshold == 300.0
assert c.sim_threshold == 0.0
assert c.parser is None

@pytest.mark.spec("QA-03")
def test_qa03_artifacts_path_is_stored(tmp_path):
path=str(tmp_path/"models")
assert EvidenceChunker(artifacts_path=path).artifacts_path == path

@pytest.mark.spec("QA-04")
def test_qa04_parameters_change_context_selection():
t=table()
p=parsed([t],[text(0,"near paragraph",center=350),text(1,"far paragraph",center=150)])
narrow=EvidenceChunker(parser=StaticParser(p),bbox_threshold=100,sim_threshold=0)
wide=EvidenceChunker(parser=StaticParser(p),bbox_threshold=300,sim_threshold=0)
a=narrow.chunk("demo.pdf")[0]
b=wide.chunk("demo.pdf")[0]
assert a.context_before == ["near paragraph"]
assert b.context_before == ["near paragraph","far paragraph"]
assert EvidenceChunker(sim_threshold=0.9).sim_threshold == 0.9

@pytest.mark.spec("QA-05")
def test_qa05_chunk_preserves_normal_and_split_table_data():
p=parsed([table(0,rows=2),table(1,page=2,rows=24,prefix="L")])
result=EvidenceChunker(parser=StaticParser(p)).chunk("demo.pdf")
assert all(isinstance(e,EvidenceUnit) for e in result)
assert {e.table_index for e in result} == {0,1}
normal=[e for e in result if e.table_index==0]
split=[e for e in result if e.table_index==1]
assert len(normal)==1 and not normal[0].is_split
assert len(split)>1 and all(e.is_split for e in split)
assert normal[0].flattened_rows==expected_sentences(2)
assert [s for e in split for s in e.flattened_rows]==expected_sentences(24,"L")

@pytest.mark.spec("QA-05")
def test_qa05_no_tables_returns_empty_list():
assert EvidenceChunker(parser=StaticParser(parsed())).chunk("empty.pdf")==[]

@pytest.mark.spec("QA-06")
@pytest.mark.parametrize("doc_id,expected",[(None,"my-paper"),("custom-doc","custom-doc")])
def test_qa06_document_id(doc_id,expected):
e=EvidenceChunker(parser=StaticParser(parsed([table()]))).chunk("folder/my-paper.pdf",doc_id=doc_id)[0]
assert e.eu_id==f"{expected}-p1-0"
assert e.metadata["doc_id"]==expected
assert e.metadata["eu_id"]==e.eu_id

@pytest.mark.spec("QA-08")
def test_qa08_adapter_preserves_counts_refs_and_coordinates():
# Adapter input double: tests conversion only, not PDF recognition.
class Item:
def __init__(self,data): self.data=data
def model_dump(self): return self.data
class RawTable(Item):
def export_to_html(self,doc): return "<table><tr><td>42</td></tr></table>"
bbox={"l":10,"t":900,"r":100,"b":800}
provenance=[{"page_no":1,"bbox":bbox}]
texts=[Item({"text":"Table 1: Values","label":"caption","prov":provenance})]
raw=RawTable({"prov":provenance,"data":{"table_cells":[],"num_rows":1,"num_cols":1},
"captions":[{"cref":"#/texts/0"}],"footnotes":[]})
doc=SimpleNamespace(texts=texts,tables=[raw],pictures=[],pages={
1:Item({"size":{"width":600,"height":1000}}),
2:Item({"size":{"width":600,"height":1000}}),
})
p=DoclingParser().from_doc(doc)
assert len(p.texts)==1 and len(p.tables)==1 and len(p.page_sizes)==2
assert p.tables[0].caption_refs==[0]
assert p.tables[0].bbox==(10,100,100,200)
assert p.texts[0].bbox==(10,100,100,200)

@pytest.mark.spec("QA-16")
def test_qa16_missing_caption_still_builds_eu():
e=build_evidence_units(parsed([table()]))[0]
assert e.caption_text is None and e.caption_confidence=="none"

@pytest.mark.spec("QA-19")
@pytest.mark.parametrize("with_notes",[False,True])
def test_qa19_first_footnote_only(with_notes):
t=table()
ts=[text(0,"First footnote",label="other"),text(1,"Second footnote",label="other")]
t.footnote_refs=[0,1] if with_notes else []
e=build_evidence_units(parsed([t],ts))[0]
assert e.footnote_text==("First footnote" if with_notes else None)
assert "Second footnote" not in e.text

@pytest.mark.spec("QA-30")
def test_qa30_evidence_contains_available_components():
from ._data import raw_unit
e=raw_unit(2)
for value in ["Results","Table 1: Measurement",
"Measurements use the same evaluation conditions.",
"Only validated measurements are reported.",
"Additional observations follow the measurement.",e.table_html]:
assert value in e.text
missing=build_evidence_units(parsed([table()]))[0]
assert missing.caption_text is None and missing.footnote_text is None
assert missing.context_before==[] and missing.context_after==[]

@pytest.mark.spec("QA-31")
def test_qa31_text_views_and_cell_unit_prefix():
e=EvidenceUnit(eu_id="x",page_no=1,caption_text="Table 1: Values",
table_html="<table>ONLY_HTML</table>",
flattened_rows=["A | Value: 10","B | Value: 20"],
context_before=["before"])
assert "<table>" in e.text and "ONLY_HTML" not in e.retrieval_text
assert "[Table 1: Values] A | Value: 10" in e.retrieval_units
assert "[Table 1: Values] B | Value: 20" in e.retrieval_units
assert len([s for s in e.retrieval_units if "| Value:" in s])==2

@pytest.mark.spec("QA-32")
def test_qa32_metadata_keys_unique_ids_and_bbox():
p=parsed([table(0,rows=2),table(1,page=2,rows=24,prefix="L")])
eus=EvidenceChunker(parser=StaticParser(p)).chunk("demo.pdf")
assert len({e.eu_id for e in eus})==len(eus)
for e in eus:
assert set(e.metadata)==EU_KEYS
assert all(0<=v<=1 for v in e.bbox)
assert e.bbox[0]<=e.bbox[2] and e.bbox[1]<=e.bbox[3]

@pytest.mark.spec("QA-39")
def test_qa39_consumed_paragraph_removed_unrelated_preserved():
consumed="This paragraph is already part of table evidence."
a=SimpleNamespace(text=consumed)
b=SimpleNamespace(text="Independent paragraph with separate content.")
eu=EvidenceUnit(eu_id="x",page_no=1,context_before=[consumed])
assert filter_consumed_paragraphs([a,b],[eu])==[b]

Loading
Loading