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,64 @@
|
||||
"""Baseline snapshot persistence: round-trip and the committed sample fixture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ragcore.evaluation.metrics import AggregateMetrics, MetricsReport, load_baseline, save_baseline
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
SAMPLE_BASELINE_PATH = REPOSITORY_ROOT / "evaluation" / "baselines" / "sample-v1.baseline.json"
|
||||
|
||||
|
||||
def make_report() -> MetricsReport:
|
||||
return MetricsReport(
|
||||
dataset_id="ds",
|
||||
dataset_version="v1",
|
||||
overall=AggregateMetrics(
|
||||
case_count=5,
|
||||
mean_recall_at_5=0.8,
|
||||
mean_recall_at_10=0.9,
|
||||
mean_reciprocal_rank=0.7,
|
||||
mean_ndcg_at_10=0.85,
|
||||
zero_result_rate=0.0,
|
||||
mean_document_diversity=2.5,
|
||||
mean_duplicate_ratio=0.1,
|
||||
mean_latency_ms=120.5,
|
||||
),
|
||||
per_tag={
|
||||
"exact-identifier": AggregateMetrics(
|
||||
case_count=2,
|
||||
mean_recall_at_5=1.0,
|
||||
mean_recall_at_10=1.0,
|
||||
mean_reciprocal_rank=1.0,
|
||||
mean_ndcg_at_10=1.0,
|
||||
zero_result_rate=0.0,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_baseline_round_trips_exactly(tmp_path: Path) -> None:
|
||||
path = tmp_path / "baseline.json"
|
||||
report = make_report()
|
||||
|
||||
save_baseline(path, report)
|
||||
recovered = load_baseline(path)
|
||||
|
||||
assert recovered == report
|
||||
|
||||
|
||||
def test_committed_sample_baseline_loads_and_validates() -> None:
|
||||
report = load_baseline(SAMPLE_BASELINE_PATH)
|
||||
|
||||
assert report.dataset_id == "sample"
|
||||
assert report.dataset_version == "v1"
|
||||
assert report.overall.case_count == 3
|
||||
assert "exact-identifier" in report.per_tag
|
||||
|
||||
|
||||
def test_reloading_the_committed_baseline_is_deterministic() -> None:
|
||||
first = load_baseline(SAMPLE_BASELINE_PATH)
|
||||
second = load_baseline(SAMPLE_BASELINE_PATH)
|
||||
|
||||
assert first == second
|
||||
@@ -0,0 +1,188 @@
|
||||
"""compute_case_metrics / aggregate_metrics against a small evaluation run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.evaluation import (
|
||||
AnswerabilityStatus,
|
||||
CaseResult,
|
||||
EvaluationCase,
|
||||
EvaluationDataset,
|
||||
EvaluationRun,
|
||||
RetrievalRunProfile,
|
||||
)
|
||||
from ragcore.evaluation.metrics import MetricsError, aggregate_metrics, compute_case_metrics
|
||||
|
||||
|
||||
def make_profile() -> RetrievalRunProfile:
|
||||
return RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="v1",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version="v1",
|
||||
)
|
||||
|
||||
|
||||
def test_case_with_no_relevant_chunks_is_excluded_from_scoring() -> None:
|
||||
case = EvaluationCase(
|
||||
id="no-evidence",
|
||||
question="q",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.NOT_ANSWERABLE,
|
||||
)
|
||||
result = CaseResult(
|
||||
case_id="no-evidence",
|
||||
retrieved_chunk_ids=(),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
)
|
||||
assert compute_case_metrics(case, result) is None
|
||||
|
||||
|
||||
def test_case_metrics_document_diversity_and_duplicate_ratio() -> None:
|
||||
chunk_a, chunk_b, chunk_c = uuid4(), uuid4(), uuid4()
|
||||
document_x, document_y = uuid4(), uuid4()
|
||||
case = EvaluationCase(
|
||||
id="case-1",
|
||||
question="q",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_chunk_ids=(chunk_a,),
|
||||
expected_document_ids=(document_x,),
|
||||
required_facts=("fact",),
|
||||
)
|
||||
result = CaseResult(
|
||||
case_id="case-1",
|
||||
retrieved_chunk_ids=(chunk_a, chunk_b, chunk_c),
|
||||
matched_expected_ids=(chunk_a,),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(chunk_b, chunk_c),
|
||||
)
|
||||
document_ids = {chunk_a: document_x, chunk_b: document_x, chunk_c: document_y}
|
||||
|
||||
metrics = compute_case_metrics(case, result, document_ids=document_ids)
|
||||
|
||||
assert metrics is not None
|
||||
assert metrics.document_diversity == 2 # document_x and document_y
|
||||
assert metrics.duplicate_ratio == pytest.approx(1 / 3) # 1 duplicate out of 3
|
||||
assert metrics.recall_at_5 == 1.0
|
||||
assert metrics.reciprocal_rank == 1.0
|
||||
|
||||
|
||||
def test_aggregate_metrics_overall_and_per_tag() -> None:
|
||||
matched_chunk = uuid4()
|
||||
document_id = uuid4()
|
||||
answerable_case = EvaluationCase(
|
||||
id="answerable-1",
|
||||
question="q1",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_chunk_ids=(matched_chunk,),
|
||||
expected_document_ids=(document_id,),
|
||||
required_facts=("fact",),
|
||||
tags=("exact-identifier",),
|
||||
)
|
||||
no_evidence_case = EvaluationCase(
|
||||
id="no-evidence-1",
|
||||
question="q2",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.NOT_ANSWERABLE,
|
||||
tags=("out-of-scope",),
|
||||
)
|
||||
dataset = EvaluationDataset(id="ds", version="v1", cases=(answerable_case, no_evidence_case))
|
||||
run = EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id="ds",
|
||||
dataset_version="v1",
|
||||
run_profile=make_profile(),
|
||||
started_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||
case_results=(
|
||||
CaseResult(
|
||||
case_id="answerable-1",
|
||||
retrieved_chunk_ids=(matched_chunk,),
|
||||
matched_expected_ids=(matched_chunk,),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
CaseResult(
|
||||
case_id="no-evidence-1",
|
||||
retrieved_chunk_ids=(),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
report = aggregate_metrics(dataset, run)
|
||||
|
||||
assert report.overall.case_count == 1 # only the answerable case is scored
|
||||
assert report.overall.mean_recall_at_10 == 1.0
|
||||
assert report.overall.zero_result_rate == pytest.approx(0.5) # 1 of 2 cases retrieved nothing
|
||||
assert "exact-identifier" in report.per_tag
|
||||
assert report.per_tag["exact-identifier"].mean_recall_at_10 == 1.0
|
||||
assert "out-of-scope" not in report.per_tag # no scored case carries this tag
|
||||
|
||||
|
||||
def test_aggregate_metrics_rejects_a_run_for_a_different_dataset() -> None:
|
||||
case = EvaluationCase(
|
||||
id="c",
|
||||
question="q",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_chunk_ids=(uuid4(),),
|
||||
expected_document_ids=(uuid4(),),
|
||||
required_facts=("fact",),
|
||||
)
|
||||
dataset = EvaluationDataset(id="ds", version="v1", cases=(case,))
|
||||
run = EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id="other",
|
||||
dataset_version="v1",
|
||||
run_profile=make_profile(),
|
||||
started_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||
case_results=(
|
||||
CaseResult(
|
||||
case_id="c",
|
||||
retrieved_chunk_ids=(),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
with pytest.raises(MetricsError, match="does not belong"):
|
||||
aggregate_metrics(dataset, run)
|
||||
|
||||
|
||||
def test_aggregate_metrics_requires_at_least_one_scoreable_case() -> None:
|
||||
case = EvaluationCase(
|
||||
id="c",
|
||||
question="q",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.NOT_ANSWERABLE,
|
||||
)
|
||||
dataset = EvaluationDataset(id="ds", version="v1", cases=(case,))
|
||||
run = EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id="ds",
|
||||
dataset_version="v1",
|
||||
run_profile=make_profile(),
|
||||
started_at=datetime(2026, 8, 2, tzinfo=UTC),
|
||||
case_results=(
|
||||
CaseResult(
|
||||
case_id="c",
|
||||
retrieved_chunk_ids=(),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
with pytest.raises(MetricsError, match="relevant chunks to score"):
|
||||
aggregate_metrics(dataset, run)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Metric formulas verified against hand-computed golden values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.evaluation.metrics import ndcg_at_k, recall_at_k, reciprocal_rank
|
||||
|
||||
D1, D2, D3, D4, D5 = (
|
||||
UUID(f"00000000-0000-4000-8000-00000000000{i}") for i in range(1, 6)
|
||||
)
|
||||
|
||||
|
||||
def test_recall_at_k_golden_values() -> None:
|
||||
retrieved = (D1, D2, D3, D4, D5)
|
||||
relevant = {D2, D4, D5}
|
||||
|
||||
# Top 2: {D1, D2} intersect relevant = {D2} -> 1/3
|
||||
assert recall_at_k(retrieved, relevant, 2) == pytest.approx(1 / 3)
|
||||
# Top 4: {D1, D2, D3, D4} intersect relevant = {D2, D4} -> 2/3
|
||||
assert recall_at_k(retrieved, relevant, 4) == pytest.approx(2 / 3)
|
||||
# Top 5: all relevant found -> 1.0
|
||||
assert recall_at_k(retrieved, relevant, 5) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_recall_at_k_with_no_hits_is_zero() -> None:
|
||||
assert recall_at_k((D1, D3), {D2, D4}, 2) == 0.0
|
||||
|
||||
|
||||
def test_recall_at_k_requires_nonempty_relevant() -> None:
|
||||
with pytest.raises(ValueError, match="relevant"):
|
||||
recall_at_k((D1,), set(), 5)
|
||||
|
||||
|
||||
def test_reciprocal_rank_golden_values() -> None:
|
||||
# First relevant item is at rank 3 -> 1/3.
|
||||
assert reciprocal_rank((D1, D3, D2, D4), {D2, D4}) == pytest.approx(1 / 3)
|
||||
# First relevant item is at rank 1 -> 1.0.
|
||||
assert reciprocal_rank((D2, D1, D3), {D2}) == pytest.approx(1.0)
|
||||
# No relevant item retrieved -> 0.0.
|
||||
assert reciprocal_rank((D1, D3), {D2}) == 0.0
|
||||
|
||||
|
||||
def test_ndcg_at_k_golden_value_hand_computed() -> None:
|
||||
# retrieved = [D1(rel), D2(rel), D3(non-rel), D4(rel)], relevant = {D1, D2, D4}, k=4.
|
||||
# DCG@4 = 1/log2(2) + 1/log2(3) + 0 + 1/log2(5)
|
||||
# IDCG@4 = 1/log2(2) + 1/log2(3) + 1/log2(4) (3 relevant items, ideal order)
|
||||
retrieved = (D1, D2, D3, D4)
|
||||
relevant = {D1, D2, D4}
|
||||
|
||||
dcg = 1 / math.log2(2) + 1 / math.log2(3) + 1 / math.log2(5)
|
||||
idcg = 1 / math.log2(2) + 1 / math.log2(3) + 1 / math.log2(4)
|
||||
expected = dcg / idcg
|
||||
|
||||
assert ndcg_at_k(retrieved, relevant, 4) == pytest.approx(expected)
|
||||
# Hand-verified numeric value to guard against a formula-shape regression
|
||||
# (e.g. an off-by-one rank or a wrong log base) that the symbolic
|
||||
# recomputation above would not catch.
|
||||
assert ndcg_at_k(retrieved, relevant, 4) == pytest.approx(0.967468, abs=1e-5)
|
||||
|
||||
|
||||
def test_ndcg_at_k_perfect_ranking_is_one() -> None:
|
||||
retrieved = (D1, D2, D3)
|
||||
relevant = {D1, D2, D3}
|
||||
assert ndcg_at_k(retrieved, relevant, 3) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_ndcg_at_k_no_relevant_hits_is_zero() -> None:
|
||||
assert ndcg_at_k((D1, D3), {D2, D4}, 2) == 0.0
|
||||
|
||||
|
||||
def test_formulas_reject_oversized_input() -> None:
|
||||
from ragcore.evaluation.metrics import MAX_RANKED_ITEMS
|
||||
|
||||
oversized = tuple(D1 for _ in range(MAX_RANKED_ITEMS + 1))
|
||||
with pytest.raises(ValueError, match="bounded limit"):
|
||||
recall_at_k(oversized, {D1}, 5)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""evaluate_gate: baseline-vs-candidate comparison and per-tag regression detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.evaluation.metrics import (
|
||||
AggregateMetrics,
|
||||
MetricsError,
|
||||
MetricsReport,
|
||||
RegressionMetric,
|
||||
RegressionThresholds,
|
||||
evaluate_gate,
|
||||
)
|
||||
|
||||
|
||||
def make_aggregate(**changes: object) -> AggregateMetrics:
|
||||
value: dict[str, object] = {
|
||||
"case_count": 10,
|
||||
"mean_recall_at_5": 0.8,
|
||||
"mean_recall_at_10": 0.9,
|
||||
"mean_reciprocal_rank": 0.7,
|
||||
"mean_ndcg_at_10": 0.85,
|
||||
"zero_result_rate": 0.05,
|
||||
}
|
||||
value.update(changes)
|
||||
return AggregateMetrics(**value)
|
||||
|
||||
|
||||
def make_report(
|
||||
overall: AggregateMetrics, per_tag: dict[str, AggregateMetrics] | None = None
|
||||
) -> MetricsReport:
|
||||
return MetricsReport(
|
||||
dataset_id="ds", dataset_version="v1", overall=overall, per_tag=per_tag or {}
|
||||
)
|
||||
|
||||
|
||||
def default_thresholds(**changes: object) -> RegressionThresholds:
|
||||
value: dict[str, object] = {"version": "gate-v1"}
|
||||
value.update(changes)
|
||||
return RegressionThresholds(**value)
|
||||
|
||||
|
||||
def test_identical_metrics_pass_the_gate() -> None:
|
||||
metrics = make_aggregate()
|
||||
result = evaluate_gate(make_report(metrics), make_report(metrics), default_thresholds())
|
||||
|
||||
assert result.passed
|
||||
assert result.regressions == ()
|
||||
|
||||
|
||||
def test_improved_metrics_pass_the_gate() -> None:
|
||||
baseline = make_aggregate(mean_recall_at_10=0.8)
|
||||
candidate = make_aggregate(mean_recall_at_10=0.95)
|
||||
result = evaluate_gate(make_report(baseline), make_report(candidate), default_thresholds())
|
||||
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_recall_drop_beyond_threshold_fails_the_gate() -> None:
|
||||
baseline = make_aggregate(mean_recall_at_10=0.9)
|
||||
candidate = make_aggregate(mean_recall_at_10=0.80) # 0.10 drop > default 0.05
|
||||
result = evaluate_gate(make_report(baseline), make_report(candidate), default_thresholds())
|
||||
|
||||
assert not result.passed
|
||||
assert any(f.metric is RegressionMetric.RECALL_AT_10 for f in result.regressions)
|
||||
|
||||
|
||||
def test_recall_drop_within_threshold_passes() -> None:
|
||||
baseline = make_aggregate(mean_recall_at_10=0.90)
|
||||
candidate = make_aggregate(mean_recall_at_10=0.87) # 0.03 drop <= default 0.05
|
||||
result = evaluate_gate(make_report(baseline), make_report(candidate), default_thresholds())
|
||||
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_zero_result_rate_rise_fails_the_gate() -> None:
|
||||
baseline = make_aggregate(zero_result_rate=0.02)
|
||||
candidate = make_aggregate(zero_result_rate=0.10) # 0.08 rise > default 0.02
|
||||
result = evaluate_gate(make_report(baseline), make_report(candidate), default_thresholds())
|
||||
|
||||
assert not result.passed
|
||||
assert any(f.metric is RegressionMetric.ZERO_RESULT_RATE for f in result.regressions)
|
||||
|
||||
|
||||
def test_per_tag_regression_is_highlighted_even_when_overall_passes() -> None:
|
||||
baseline = make_report(
|
||||
make_aggregate(),
|
||||
per_tag={
|
||||
"exact-identifier": make_aggregate(mean_recall_at_10=0.95),
|
||||
"conceptual": make_aggregate(mean_recall_at_10=0.85),
|
||||
},
|
||||
)
|
||||
candidate = make_report(
|
||||
make_aggregate(), # overall unchanged
|
||||
per_tag={
|
||||
"exact-identifier": make_aggregate(mean_recall_at_10=0.70), # regressed
|
||||
"conceptual": make_aggregate(mean_recall_at_10=0.86), # slightly improved
|
||||
},
|
||||
)
|
||||
|
||||
result = evaluate_gate(baseline, candidate, default_thresholds())
|
||||
|
||||
assert not result.passed
|
||||
tag_regressions = {finding.tag for finding in result.regressions}
|
||||
assert "exact-identifier" in tag_regressions
|
||||
assert "conceptual" not in tag_regressions
|
||||
|
||||
|
||||
def test_tags_only_present_in_one_report_are_not_compared() -> None:
|
||||
baseline = make_report(make_aggregate(), per_tag={"old-tag": make_aggregate()})
|
||||
candidate = make_report(
|
||||
make_aggregate(), per_tag={"new-tag": make_aggregate(mean_recall_at_10=0.0)}
|
||||
)
|
||||
|
||||
result = evaluate_gate(baseline, candidate, default_thresholds())
|
||||
|
||||
assert result.passed # only overall is compared; mismatched tags are skipped
|
||||
|
||||
|
||||
def test_gate_rejects_mismatched_datasets() -> None:
|
||||
baseline = MetricsReport(dataset_id="a", dataset_version="v1", overall=make_aggregate())
|
||||
candidate = MetricsReport(dataset_id="b", dataset_version="v1", overall=make_aggregate())
|
||||
|
||||
with pytest.raises(MetricsError, match="same dataset"):
|
||||
evaluate_gate(baseline, candidate, default_thresholds())
|
||||
|
||||
|
||||
def test_gate_result_invariants_are_enforced() -> None:
|
||||
from ragcore.evaluation.metrics import GateResult, RegressionFinding
|
||||
|
||||
with pytest.raises(MetricsError, match="must not carry regressions"):
|
||||
GateResult(
|
||||
thresholds_version="v1",
|
||||
passed=True,
|
||||
regressions=(
|
||||
RegressionFinding(
|
||||
metric=RegressionMetric.RECALL_AT_5,
|
||||
tag=None,
|
||||
baseline_value=0.9,
|
||||
candidate_value=0.5,
|
||||
delta=-0.4,
|
||||
),
|
||||
),
|
||||
)
|
||||
with pytest.raises(MetricsError, match="at least one regression"):
|
||||
GateResult(thresholds_version="v1", passed=False, regressions=())
|
||||
Reference in new issue
Block a user