Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,119 @@
|
||||
"""Versioned case/dataset schema validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.evaluation import (
|
||||
AnswerabilityStatus,
|
||||
EvaluationCase,
|
||||
EvaluationDataset,
|
||||
EvaluationDatasetError,
|
||||
RetrievalRunProfile,
|
||||
)
|
||||
|
||||
|
||||
def make_case(**changes: object) -> EvaluationCase:
|
||||
value: dict[str, object] = {
|
||||
"id": "case-1",
|
||||
"question": "What is the default timeout?",
|
||||
"language": "en",
|
||||
"expected_answerability": AnswerabilityStatus.ANSWERABLE,
|
||||
"expected_document_ids": (uuid4(),),
|
||||
"expected_chunk_ids": (uuid4(),),
|
||||
"required_facts": ("the default timeout is 30 seconds",),
|
||||
}
|
||||
value.update(changes)
|
||||
return EvaluationCase(**value)
|
||||
|
||||
|
||||
def test_valid_answerable_case_is_accepted() -> None:
|
||||
case = make_case()
|
||||
assert case.expected_answerability is AnswerabilityStatus.ANSWERABLE
|
||||
|
||||
|
||||
def test_answerable_case_requires_expected_documents() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="expect at least one document"):
|
||||
make_case(expected_document_ids=())
|
||||
|
||||
|
||||
def test_answerable_case_requires_required_facts() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="required facts"):
|
||||
make_case(required_facts=())
|
||||
|
||||
|
||||
def test_not_answerable_case_may_have_no_expected_evidence() -> None:
|
||||
case = make_case(
|
||||
expected_answerability=AnswerabilityStatus.NOT_ANSWERABLE,
|
||||
expected_document_ids=(),
|
||||
expected_chunk_ids=(),
|
||||
required_facts=(),
|
||||
)
|
||||
assert case.expected_document_ids == ()
|
||||
|
||||
|
||||
def test_conflicting_evidence_case_requires_two_distinct_documents() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="two distinct documents"):
|
||||
make_case(
|
||||
expected_answerability=AnswerabilityStatus.CONFLICTING_EVIDENCE,
|
||||
expected_document_ids=(uuid4(),),
|
||||
required_facts=(),
|
||||
)
|
||||
|
||||
case = make_case(
|
||||
expected_answerability=AnswerabilityStatus.CONFLICTING_EVIDENCE,
|
||||
expected_document_ids=(uuid4(), uuid4()),
|
||||
required_facts=(),
|
||||
)
|
||||
assert case.expected_answerability is AnswerabilityStatus.CONFLICTING_EVIDENCE
|
||||
|
||||
|
||||
def test_case_id_and_language_are_validated() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="identifier"):
|
||||
make_case(id="Not A Safe Id")
|
||||
with pytest.raises(EvaluationDatasetError, match="language"):
|
||||
make_case(language="english")
|
||||
|
||||
|
||||
def test_case_forbids_unbounded_collections() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="exceeds the bounded limit"):
|
||||
make_case(required_facts=tuple(f"fact {i}" for i in range(33)))
|
||||
|
||||
|
||||
def test_dataset_requires_at_least_one_case() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="between 1 and 500"):
|
||||
EvaluationDataset(id="empty", version="v1", cases=())
|
||||
|
||||
|
||||
def test_dataset_rejects_duplicate_case_ids() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="unique"):
|
||||
EvaluationDataset(id="dup", version="v1", cases=(make_case(), make_case()))
|
||||
|
||||
|
||||
def test_dataset_id_and_version_are_validated() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="identifier"):
|
||||
EvaluationDataset(id="Not Safe", version="v1", cases=(make_case(),))
|
||||
with pytest.raises(EvaluationDatasetError, match="identifier"):
|
||||
EvaluationDataset(id="dataset", version="Not Safe", cases=(make_case(),))
|
||||
|
||||
|
||||
def test_run_profile_fields_are_bounded_and_non_empty() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="embedding_profile_digest"):
|
||||
RetrievalRunProfile(
|
||||
embedding_profile_digest="",
|
||||
sparse_profile_version="v1",
|
||||
index_alias="rag_dense_x_active",
|
||||
index_collection_version="v1",
|
||||
)
|
||||
|
||||
|
||||
def test_run_profile_allows_no_reranker() -> None:
|
||||
profile = RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="v1",
|
||||
index_alias="rag_dense_x_active",
|
||||
index_collection_version="v1",
|
||||
)
|
||||
assert profile.reranker_profile_digest is None
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Loading and validating dataset JSON files against the versioned schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.evaluation import AnswerabilityStatus, EvaluationDatasetError, load_dataset
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
SAMPLE_DATASET_PATH = REPOSITORY_ROOT / "evaluation" / "datasets" / "sample-v1.json"
|
||||
|
||||
|
||||
def test_sample_fixture_dataset_loads_and_validates() -> None:
|
||||
dataset = load_dataset(SAMPLE_DATASET_PATH)
|
||||
|
||||
assert dataset.id == "sample"
|
||||
assert dataset.version == "v1"
|
||||
assert len(dataset.cases) == 4
|
||||
case_by_id = {case.id: case for case in dataset.cases}
|
||||
assert case_by_id["exact-error-code-lookup"].expected_answerability is (
|
||||
AnswerabilityStatus.ANSWERABLE
|
||||
)
|
||||
assert case_by_id["conflicting-version-docs"].expected_answerability is (
|
||||
AnswerabilityStatus.CONFLICTING_EVIDENCE
|
||||
)
|
||||
assert case_by_id["no-relevant-evidence"].expected_document_ids == ()
|
||||
|
||||
|
||||
def test_loader_rejects_missing_file() -> None:
|
||||
with pytest.raises(EvaluationDatasetError, match="could not be read"):
|
||||
load_dataset(REPOSITORY_ROOT / "evaluation" / "datasets" / "does-not-exist.json")
|
||||
|
||||
|
||||
def test_loader_rejects_invalid_json(tmp_path: Path) -> None:
|
||||
path = tmp_path / "broken.json"
|
||||
path.write_text("{not valid json", encoding="utf-8")
|
||||
with pytest.raises(EvaluationDatasetError, match="valid UTF-8 JSON"):
|
||||
load_dataset(path)
|
||||
|
||||
|
||||
def test_loader_rejects_non_object_payload(tmp_path: Path) -> None:
|
||||
path = tmp_path / "list.json"
|
||||
path.write_text(json.dumps(["not", "an", "object"]), encoding="utf-8")
|
||||
with pytest.raises(EvaluationDatasetError, match="JSON object"):
|
||||
load_dataset(path)
|
||||
|
||||
|
||||
def test_loader_rejects_malformed_uuid(tmp_path: Path) -> None:
|
||||
path = tmp_path / "bad-uuid.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": "bad",
|
||||
"version": "v1",
|
||||
"cases": [
|
||||
{
|
||||
"id": "case-1",
|
||||
"question": "q",
|
||||
"language": "en",
|
||||
"expected_answerability": "not_answerable",
|
||||
"expected_document_ids": ["not-a-uuid"],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(EvaluationDatasetError, match="UUID strings"):
|
||||
load_dataset(path)
|
||||
|
||||
|
||||
def test_loader_reloading_the_same_file_is_deterministic() -> None:
|
||||
first = load_dataset(SAMPLE_DATASET_PATH)
|
||||
second = load_dataset(SAMPLE_DATASET_PATH)
|
||||
|
||||
assert first == second
|
||||
@@ -0,0 +1,119 @@
|
||||
"""EvaluationRunner: offline fixture-corpus runs with reproducible results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.evaluation import (
|
||||
AnswerabilityStatus,
|
||||
EvaluationCase,
|
||||
EvaluationDataset,
|
||||
EvaluationDatasetError,
|
||||
EvaluationRunner,
|
||||
FixtureCorpusRetriever,
|
||||
RetrievalRunProfile,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def make_dataset() -> tuple[EvaluationDataset, dict]:
|
||||
matched_chunk, missing_chunk, unexpected_chunk = uuid4(), uuid4(), uuid4()
|
||||
case = EvaluationCase(
|
||||
id="case-1",
|
||||
question="What is the timeout?",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_document_ids=(uuid4(),),
|
||||
expected_chunk_ids=(matched_chunk, missing_chunk),
|
||||
required_facts=("the timeout is bounded",),
|
||||
)
|
||||
dataset = EvaluationDataset(id="offline", version="v1", cases=(case,))
|
||||
corpus = {"case-1": (matched_chunk, unexpected_chunk)}
|
||||
return dataset, {
|
||||
"corpus": corpus,
|
||||
"matched": matched_chunk,
|
||||
"missing": missing_chunk,
|
||||
"unexpected": unexpected_chunk,
|
||||
}
|
||||
|
||||
|
||||
def make_profile() -> RetrievalRunProfile:
|
||||
return RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="lexical-v1",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version="v1",
|
||||
)
|
||||
|
||||
|
||||
async def test_runner_works_offline_with_a_fixture_corpus() -> None:
|
||||
dataset, ids = make_dataset()
|
||||
retriever = FixtureCorpusRetriever(ids["corpus"])
|
||||
runner = EvaluationRunner(retriever, clock=lambda: datetime(2026, 8, 2, tzinfo=UTC))
|
||||
|
||||
run = await runner.run(dataset, run_profile=make_profile())
|
||||
|
||||
assert run.dataset_id == "offline"
|
||||
assert run.dataset_version == "v1"
|
||||
assert len(run.case_results) == 1
|
||||
result = run.case_results[0]
|
||||
assert result.matched_expected_ids == (ids["matched"],)
|
||||
assert result.missing_expected_ids == (ids["missing"],)
|
||||
assert result.unexpected_ids == (ids["unexpected"],)
|
||||
|
||||
|
||||
async def test_run_records_profile_model_and_index_revisions() -> None:
|
||||
dataset, ids = make_dataset()
|
||||
runner = EvaluationRunner(FixtureCorpusRetriever(ids["corpus"]))
|
||||
profile = RetrievalRunProfile(
|
||||
embedding_profile_digest="b" * 64,
|
||||
sparse_profile_version="lexical-v2",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version="v3",
|
||||
reranker_profile_digest="c" * 64,
|
||||
)
|
||||
|
||||
run = await runner.run(dataset, run_profile=profile)
|
||||
|
||||
assert run.run_profile.embedding_profile_digest == "b" * 64
|
||||
assert run.run_profile.sparse_profile_version == "lexical-v2"
|
||||
assert run.run_profile.index_collection_version == "v3"
|
||||
assert run.run_profile.reranker_profile_digest == "c" * 64
|
||||
|
||||
|
||||
async def test_results_are_reproducible_given_identical_inputs() -> None:
|
||||
dataset, ids = make_dataset()
|
||||
run_id = uuid4()
|
||||
fixed_time = datetime(2026, 8, 2, 9, tzinfo=UTC)
|
||||
runner_a = EvaluationRunner(FixtureCorpusRetriever(ids["corpus"]), clock=lambda: fixed_time)
|
||||
runner_b = EvaluationRunner(FixtureCorpusRetriever(ids["corpus"]), clock=lambda: fixed_time)
|
||||
|
||||
run_a = await runner_a.run(dataset, run_profile=make_profile(), run_id=run_id)
|
||||
run_b = await runner_b.run(dataset, run_profile=make_profile(), run_id=run_id)
|
||||
|
||||
assert run_a == run_b
|
||||
|
||||
|
||||
async def test_runner_rejects_a_retriever_exceeding_the_bounded_limit() -> None:
|
||||
dataset, _ids = make_dataset()
|
||||
oversized = {"case-1": tuple(uuid4() for _ in range(201))}
|
||||
# FixtureCorpusRetriever itself would reject this at construction time; a
|
||||
# raw retriever proves the runner independently enforces the limit too,
|
||||
# regardless of which Reranker-style implementation is plugged in.
|
||||
runner = EvaluationRunner(_RawRetriever(oversized))
|
||||
|
||||
with pytest.raises(EvaluationDatasetError, match="bounded limit"):
|
||||
await runner.run(dataset, run_profile=make_profile())
|
||||
|
||||
|
||||
class _RawRetriever:
|
||||
def __init__(self, corpus: dict) -> None:
|
||||
self._corpus = corpus
|
||||
|
||||
async def retrieve(self, case: EvaluationCase) -> tuple:
|
||||
return self._corpus.get(case.id, ())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""JsonEvaluationRunStore: atomic persistence and exact round-trip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.evaluation import (
|
||||
CaseResult,
|
||||
EvaluationDatasetError,
|
||||
EvaluationRun,
|
||||
JsonEvaluationRunStore,
|
||||
RetrievalRunProfile,
|
||||
)
|
||||
|
||||
|
||||
def make_run(run_id=None) -> EvaluationRun:
|
||||
return EvaluationRun(
|
||||
id=run_id or uuid4(),
|
||||
dataset_id="offline",
|
||||
dataset_version="v1",
|
||||
run_profile=RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="lexical-v1",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version="v1",
|
||||
),
|
||||
started_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||
case_results=(
|
||||
CaseResult(
|
||||
case_id="case-1",
|
||||
retrieved_chunk_ids=(uuid4(),),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(uuid4(),),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_stored_run_round_trips_exactly(tmp_path: Path) -> None:
|
||||
store = JsonEvaluationRunStore(tmp_path / "runs.json")
|
||||
run = make_run()
|
||||
|
||||
store.add(run)
|
||||
|
||||
recovered_store = JsonEvaluationRunStore(tmp_path / "runs.json")
|
||||
assert recovered_store.get(run.id) == run
|
||||
|
||||
|
||||
def test_missing_run_returns_none(tmp_path: Path) -> None:
|
||||
store = JsonEvaluationRunStore(tmp_path / "runs.json")
|
||||
assert store.get(uuid4()) is None
|
||||
|
||||
|
||||
def test_duplicate_run_id_is_rejected(tmp_path: Path) -> None:
|
||||
store = JsonEvaluationRunStore(tmp_path / "runs.json")
|
||||
run = make_run()
|
||||
store.add(run)
|
||||
|
||||
with pytest.raises(EvaluationDatasetError, match="already stored"):
|
||||
store.add(run)
|
||||
|
||||
|
||||
def test_all_returns_every_stored_run(tmp_path: Path) -> None:
|
||||
store = JsonEvaluationRunStore(tmp_path / "runs.json")
|
||||
first, second = make_run(), make_run()
|
||||
store.add(first)
|
||||
store.add(second)
|
||||
|
||||
stored_ids = {run.id for run in store.all()}
|
||||
assert stored_ids == {first.id, second.id}
|
||||
Reference in new issue
Block a user