feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
This commit is contained in:
@@ -0,0 +1,776 @@
|
||||
"""Tests for evidence verification, entailment, coverage metrics, rejected store, and metrics.
|
||||
|
||||
Covers:
|
||||
- Valid offset verification
|
||||
- Invalid offset detection (text mismatch, out of bounds)
|
||||
- Entity-evidence association
|
||||
- Numeric consistency (value found / not found in evidence)
|
||||
- Rejected candidate storage with reason codes
|
||||
- RejectedCandidateStore (store, get_by_pipeline_run, get_by_reason)
|
||||
- Entailment baseline (keyword overlap and exact match)
|
||||
- Coverage metrics computation
|
||||
- VerificationMetrics aggregation (unsupported-claim and evidence-coverage rates)
|
||||
- Full verification report
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from services.intelligence_pipeline_v3.verification.coverage import (
|
||||
FieldEvidence,
|
||||
compute_coverage,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.verification.entailment import (
|
||||
EntailmentVerifier,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.verification.metrics import (
|
||||
VerificationMetrics,
|
||||
compute_verification_metrics,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.verification.models import (
|
||||
RejectedCandidate,
|
||||
RejectionReason,
|
||||
VerificationReport,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.verification.rejected_store import (
|
||||
RejectedCandidateStore,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.verification.verifier import (
|
||||
Candidate,
|
||||
Entity,
|
||||
EvidenceSpan,
|
||||
EvidenceVerifier,
|
||||
NumericFact,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SOURCE_TEXT = (
|
||||
"Apple Inc. reported revenue of $94.8 billion for Q1 2024, "
|
||||
"beating analyst expectations of $92.0 billion. "
|
||||
"CEO Tim Cook said the company saw strong growth in services."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_text() -> str:
|
||||
return SOURCE_TEXT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_spans(source_text: str) -> list[EvidenceSpan]:
|
||||
"""Spans that exactly match the source text at declared offsets."""
|
||||
return [
|
||||
EvidenceSpan(
|
||||
id="span-1",
|
||||
start_char=0,
|
||||
end_char=10,
|
||||
text=source_text[0:10], # "Apple Inc."
|
||||
),
|
||||
EvidenceSpan(
|
||||
id="span-2",
|
||||
start_char=11,
|
||||
end_char=58,
|
||||
text=source_text[11:58],
|
||||
),
|
||||
EvidenceSpan(
|
||||
id="span-3",
|
||||
start_char=60,
|
||||
end_char=107,
|
||||
text=source_text[60:107],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verifier() -> EvidenceVerifier:
|
||||
return EvidenceVerifier()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Valid offset verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOffsetVerification:
|
||||
def test_valid_offsets_pass(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan], source_text: str
|
||||
):
|
||||
results = verifier.verify_offsets(valid_spans, source_text)
|
||||
assert len(results) == 3
|
||||
assert all(r.valid for r in results)
|
||||
assert all(r.reason is None for r in results)
|
||||
|
||||
def test_text_mismatch_detected(self, verifier: EvidenceVerifier, source_text: str):
|
||||
"""Span with text that doesn't match source at the declared offset."""
|
||||
bad_span = EvidenceSpan(
|
||||
id="span-bad",
|
||||
start_char=0,
|
||||
end_char=10,
|
||||
text="Google LLC", # Wrong — source has "Apple Inc."
|
||||
)
|
||||
results = verifier.verify_offsets([bad_span], source_text)
|
||||
assert len(results) == 1
|
||||
assert not results[0].valid
|
||||
assert "Text mismatch" in results[0].reason
|
||||
|
||||
def test_offset_out_of_bounds(self, verifier: EvidenceVerifier, source_text: str):
|
||||
"""Span with end_char beyond source text length."""
|
||||
bad_span = EvidenceSpan(
|
||||
id="span-oob",
|
||||
start_char=0,
|
||||
end_char=len(source_text) + 100,
|
||||
text="doesn't matter",
|
||||
)
|
||||
results = verifier.verify_offsets([bad_span], source_text)
|
||||
assert len(results) == 1
|
||||
assert not results[0].valid
|
||||
assert "out of bounds" in results[0].reason.lower()
|
||||
|
||||
def test_invalid_range_end_before_start(self, verifier: EvidenceVerifier, source_text: str):
|
||||
"""Span where end_char <= start_char."""
|
||||
bad_span = EvidenceSpan(
|
||||
id="span-reversed",
|
||||
start_char=10,
|
||||
end_char=5,
|
||||
text="x",
|
||||
)
|
||||
results = verifier.verify_offsets([bad_span], source_text)
|
||||
assert len(results) == 1
|
||||
assert not results[0].valid
|
||||
assert "Invalid range" in results[0].reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Entity-evidence association
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEntityAssociation:
|
||||
def test_entity_found_in_evidence(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
entity = Entity(
|
||||
id="ent-1", literal_text="Apple Inc.", evidence_ids=["span-1"]
|
||||
)
|
||||
result = verifier.verify_entity_association(entity, valid_spans)
|
||||
assert result.valid
|
||||
assert result.reason is None
|
||||
|
||||
def test_entity_case_insensitive(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
"""Entity matching should be case-insensitive."""
|
||||
entity = Entity(
|
||||
id="ent-2", literal_text="apple inc.", evidence_ids=["span-1"]
|
||||
)
|
||||
result = verifier.verify_entity_association(entity, valid_spans)
|
||||
assert result.valid
|
||||
|
||||
def test_entity_not_in_evidence(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
"""Entity text is not present in any linked span."""
|
||||
entity = Entity(
|
||||
id="ent-3", literal_text="Microsoft", evidence_ids=["span-1", "span-2"]
|
||||
)
|
||||
result = verifier.verify_entity_association(entity, valid_spans)
|
||||
assert not result.valid
|
||||
assert "not found" in result.reason.lower()
|
||||
|
||||
def test_entity_with_nonexistent_span_id(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
"""Entity references a span ID that doesn't exist."""
|
||||
entity = Entity(
|
||||
id="ent-4", literal_text="Apple", evidence_ids=["span-nonexistent"]
|
||||
)
|
||||
result = verifier.verify_entity_association(entity, valid_spans)
|
||||
assert not result.valid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Numeric consistency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNumericConsistency:
|
||||
def test_literal_value_found(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
"""Literal value string is found directly in evidence text."""
|
||||
fact = NumericFact(
|
||||
id="fact-1",
|
||||
literal_value="$94.8 billion",
|
||||
normalized_value=94.8,
|
||||
evidence_ids=["span-2"],
|
||||
)
|
||||
result = verifier.verify_numeric_consistency(fact, valid_spans)
|
||||
assert result.valid
|
||||
assert result.found_value == "$94.8 billion"
|
||||
|
||||
def test_normalized_value_match(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
"""Normalized value matches a number in evidence (without exact literal)."""
|
||||
fact = NumericFact(
|
||||
id="fact-2",
|
||||
literal_value="92 billion", # Not exact match
|
||||
normalized_value=92.0,
|
||||
evidence_ids=["span-3"],
|
||||
)
|
||||
result = verifier.verify_numeric_consistency(fact, valid_spans)
|
||||
assert result.valid
|
||||
assert result.found_value == "92.0"
|
||||
|
||||
def test_value_not_found(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
"""Value doesn't appear in any linked evidence."""
|
||||
fact = NumericFact(
|
||||
id="fact-3",
|
||||
literal_value="$200 billion",
|
||||
normalized_value=200.0,
|
||||
evidence_ids=["span-2", "span-3"],
|
||||
)
|
||||
result = verifier.verify_numeric_consistency(fact, valid_spans)
|
||||
assert not result.valid
|
||||
assert result.found_value is None
|
||||
assert "not found" in result.reason.lower()
|
||||
|
||||
def test_tolerance_matching(self, valid_spans: list[EvidenceSpan]):
|
||||
"""Values within tolerance should match."""
|
||||
verifier = EvidenceVerifier(numeric_tolerance=0.02) # 2% tolerance
|
||||
fact = NumericFact(
|
||||
id="fact-4",
|
||||
literal_value="93.8",
|
||||
normalized_value=93.8, # Within 2% of 94.8
|
||||
evidence_ids=["span-2"],
|
||||
)
|
||||
result = verifier.verify_numeric_consistency(fact, valid_spans)
|
||||
assert result.valid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Rejected candidate storage (in verifier)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRejectedCandidates:
|
||||
def test_offset_rejection_stored(self, verifier: EvidenceVerifier, source_text: str):
|
||||
bad_span = EvidenceSpan(
|
||||
id="span-bad",
|
||||
start_char=0,
|
||||
end_char=10,
|
||||
text="WRONG TEXT",
|
||||
)
|
||||
verifier.verify_offsets([bad_span], source_text)
|
||||
rejected = verifier.rejected_candidates
|
||||
assert len(rejected) == 1
|
||||
assert rejected[0].rejection_reason == RejectionReason.TEXT_MISMATCH
|
||||
assert rejected[0].candidate_type == "evidence_span"
|
||||
assert rejected[0].stage == "offset_verification"
|
||||
|
||||
def test_entity_rejection_stored(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
entity = Entity(
|
||||
id="ent-bad", literal_text="Nonexistent Corp", evidence_ids=["span-1"]
|
||||
)
|
||||
verifier.verify_entity_association(entity, valid_spans)
|
||||
rejected = verifier.rejected_candidates
|
||||
assert len(rejected) == 1
|
||||
assert rejected[0].rejection_reason == RejectionReason.ENTITY_NOT_IN_EVIDENCE
|
||||
assert rejected[0].candidate_type == "entity"
|
||||
assert rejected[0].candidate_data["entity_id"] == "ent-bad"
|
||||
|
||||
def test_numeric_rejection_stored(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
fact = NumericFact(
|
||||
id="fact-bad",
|
||||
literal_value="$999",
|
||||
normalized_value=999.0,
|
||||
evidence_ids=["span-2"],
|
||||
)
|
||||
verifier.verify_numeric_consistency(fact, valid_spans)
|
||||
rejected = verifier.rejected_candidates
|
||||
assert len(rejected) == 1
|
||||
assert rejected[0].rejection_reason == RejectionReason.NUMERIC_INCONSISTENCY
|
||||
assert rejected[0].candidate_type == "fact"
|
||||
|
||||
def test_reset_clears_rejected(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
entity = Entity(
|
||||
id="ent-x", literal_text="Nothing", evidence_ids=["span-1"]
|
||||
)
|
||||
verifier.verify_entity_association(entity, valid_spans)
|
||||
assert len(verifier.rejected_candidates) == 1
|
||||
verifier.reset()
|
||||
assert len(verifier.rejected_candidates) == 0
|
||||
|
||||
def test_rejection_has_timestamp(
|
||||
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
|
||||
):
|
||||
entity = Entity(
|
||||
id="ent-ts", literal_text="Nobody", evidence_ids=["span-1"]
|
||||
)
|
||||
verifier.verify_entity_association(entity, valid_spans)
|
||||
rejected = verifier.rejected_candidates
|
||||
assert rejected[0].timestamp is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: RejectedCandidateStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRejectedCandidateStore:
|
||||
def test_store_and_retrieve_by_run(self):
|
||||
store = RejectedCandidateStore()
|
||||
rc = RejectedCandidate(
|
||||
candidate_type="entity",
|
||||
candidate_data={"entity_id": "e1", "text": "Apple"},
|
||||
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
|
||||
stage="entity_verification",
|
||||
)
|
||||
store.store(rc, run_id="run-001")
|
||||
results = store.get_by_pipeline_run("run-001")
|
||||
assert len(results) == 1
|
||||
assert results[0].candidate_data["entity_id"] == "e1"
|
||||
|
||||
def test_retrieve_empty_run(self):
|
||||
store = RejectedCandidateStore()
|
||||
results = store.get_by_pipeline_run("nonexistent-run")
|
||||
assert results == []
|
||||
|
||||
def test_store_and_retrieve_by_reason(self):
|
||||
store = RejectedCandidateStore()
|
||||
rc1 = RejectedCandidate(
|
||||
candidate_type="entity",
|
||||
candidate_data={"id": "e1"},
|
||||
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
|
||||
stage="entity_verification",
|
||||
)
|
||||
rc2 = RejectedCandidate(
|
||||
candidate_type="fact",
|
||||
candidate_data={"id": "f1"},
|
||||
rejection_reason=RejectionReason.NUMERIC_INCONSISTENCY,
|
||||
stage="numeric_verification",
|
||||
)
|
||||
rc3 = RejectedCandidate(
|
||||
candidate_type="entity",
|
||||
candidate_data={"id": "e2"},
|
||||
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
|
||||
stage="entity_verification",
|
||||
)
|
||||
store.store(rc1, run_id="run-1")
|
||||
store.store(rc2, run_id="run-1")
|
||||
store.store(rc3, run_id="run-2")
|
||||
|
||||
by_entity = store.get_by_reason(RejectionReason.ENTITY_NOT_IN_EVIDENCE)
|
||||
assert len(by_entity) == 2
|
||||
|
||||
by_numeric = store.get_by_reason(RejectionReason.NUMERIC_INCONSISTENCY)
|
||||
assert len(by_numeric) == 1
|
||||
|
||||
def test_store_batch(self):
|
||||
store = RejectedCandidateStore()
|
||||
batch = [
|
||||
RejectedCandidate(
|
||||
candidate_type="entity",
|
||||
candidate_data={"id": f"e{i}"},
|
||||
rejection_reason=RejectionReason.INVALID_OFFSET,
|
||||
stage="offset_verification",
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
store.store_batch(batch, run_id="run-batch")
|
||||
assert store.count() == 5
|
||||
assert len(store.get_by_pipeline_run("run-batch")) == 5
|
||||
|
||||
|
||||
def test_count_by_reason(self):
|
||||
store = RejectedCandidateStore()
|
||||
store.store(RejectedCandidate(
|
||||
candidate_type="span",
|
||||
candidate_data={},
|
||||
rejection_reason=RejectionReason.INVALID_OFFSET,
|
||||
stage="offset",
|
||||
))
|
||||
store.store(RejectedCandidate(
|
||||
candidate_type="span",
|
||||
candidate_data={},
|
||||
rejection_reason=RejectionReason.INVALID_OFFSET,
|
||||
stage="offset",
|
||||
))
|
||||
store.store(RejectedCandidate(
|
||||
candidate_type="fact",
|
||||
candidate_data={},
|
||||
rejection_reason=RejectionReason.NUMERIC_INCONSISTENCY,
|
||||
stage="numeric",
|
||||
))
|
||||
counts = store.count_by_reason()
|
||||
assert counts["invalid_offset"] == 2
|
||||
assert counts["numeric_inconsistency"] == 1
|
||||
|
||||
def test_clear(self):
|
||||
store = RejectedCandidateStore()
|
||||
store.store(RejectedCandidate(
|
||||
candidate_type="entity",
|
||||
candidate_data={},
|
||||
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
|
||||
stage="test",
|
||||
), run_id="run-1")
|
||||
assert store.count() == 1
|
||||
store.clear()
|
||||
assert store.count() == 0
|
||||
assert store.get_by_pipeline_run("run-1") == []
|
||||
assert store.get_by_reason(RejectionReason.ENTITY_NOT_IN_EVIDENCE) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Entailment baseline (keyword overlap)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEntailment:
|
||||
def test_exact_match_entailment(self):
|
||||
ev = EntailmentVerifier()
|
||||
result = ev.verify_claim(
|
||||
claim="reported revenue of $94.8 billion",
|
||||
evidence="Apple Inc. reported revenue of $94.8 billion for Q1 2024",
|
||||
)
|
||||
assert result.entailed
|
||||
assert result.confidence == 1.0
|
||||
assert result.method == "exact_match"
|
||||
|
||||
def test_keyword_overlap_entailed(self):
|
||||
ev = EntailmentVerifier(keyword_threshold=0.5)
|
||||
result = ev.verify_claim(
|
||||
claim="Apple revenue grew significantly",
|
||||
evidence="Apple Inc. reported record revenue growth of 15% year-over-year",
|
||||
)
|
||||
assert result.entailed
|
||||
assert result.method == "keyword_overlap"
|
||||
assert result.confidence >= 0.5
|
||||
|
||||
def test_keyword_overlap_not_entailed(self):
|
||||
ev = EntailmentVerifier(keyword_threshold=0.6)
|
||||
result = ev.verify_claim(
|
||||
claim="Microsoft acquired a gaming company",
|
||||
evidence="Apple Inc. reported revenue of $94.8 billion for Q1 2024",
|
||||
)
|
||||
assert not result.entailed
|
||||
assert result.method == "keyword_overlap"
|
||||
assert result.confidence < 0.6
|
||||
|
||||
def test_empty_claim(self):
|
||||
ev = EntailmentVerifier()
|
||||
result = ev.verify_claim(claim="", evidence="Some evidence text")
|
||||
assert not result.entailed
|
||||
assert result.confidence == 0.0
|
||||
|
||||
def test_empty_evidence(self):
|
||||
ev = EntailmentVerifier()
|
||||
result = ev.verify_claim(claim="Some claim", evidence="")
|
||||
assert not result.entailed
|
||||
assert result.confidence == 0.0
|
||||
|
||||
def test_batch_verification(self):
|
||||
ev = EntailmentVerifier()
|
||||
claims = [
|
||||
"reported revenue",
|
||||
"completely unrelated topic about cats",
|
||||
]
|
||||
evidence = "Apple reported revenue of $94.8 billion"
|
||||
results = ev.verify_claims_batch(claims, evidence)
|
||||
assert len(results) == 2
|
||||
assert results[0].entailed # "reported revenue" is in evidence
|
||||
assert not results[1].entailed # cats not related
|
||||
|
||||
def test_model_version_present(self):
|
||||
"""EntailmentResult includes model_version field."""
|
||||
ev = EntailmentVerifier()
|
||||
result = ev.verify_claim(
|
||||
claim="revenue growth",
|
||||
evidence="The company reported strong revenue growth this quarter.",
|
||||
)
|
||||
assert result.model_version == "keyword_overlap_v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Coverage metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCoverageMetrics:
|
||||
def test_full_coverage(self):
|
||||
fields = [
|
||||
FieldEvidence(field_id="f1", field_name="revenue", evidence_ids=["s1", "s2"]),
|
||||
FieldEvidence(field_id="f2", field_name="eps", evidence_ids=["s2"]),
|
||||
]
|
||||
verified = {"s1", "s2", "s3"}
|
||||
metrics = compute_coverage(fields, verified)
|
||||
assert metrics.total_fields == 2
|
||||
assert metrics.supported_fields == 2
|
||||
assert metrics.coverage_rate == 1.0
|
||||
assert metrics.unsupported_claims == []
|
||||
assert metrics.unsupported_rate == 0.0
|
||||
|
||||
def test_partial_coverage(self):
|
||||
fields = [
|
||||
FieldEvidence(field_id="f1", field_name="revenue", evidence_ids=["s1"]),
|
||||
FieldEvidence(field_id="f2", field_name="eps", evidence_ids=["s4"]),
|
||||
FieldEvidence(field_id="f3", field_name="guidance", evidence_ids=["s2"]),
|
||||
]
|
||||
verified = {"s1", "s2", "s3"}
|
||||
metrics = compute_coverage(fields, verified)
|
||||
assert metrics.total_fields == 3
|
||||
assert metrics.supported_fields == 2
|
||||
assert metrics.coverage_rate == pytest.approx(2 / 3)
|
||||
assert metrics.unsupported_claims == ["f2"]
|
||||
assert metrics.unsupported_rate == pytest.approx(1 / 3)
|
||||
|
||||
def test_no_coverage(self):
|
||||
fields = [
|
||||
FieldEvidence(field_id="f1", field_name="revenue", evidence_ids=["s99"]),
|
||||
FieldEvidence(field_id="f2", field_name="eps", evidence_ids=["s100"]),
|
||||
]
|
||||
verified = {"s1", "s2"}
|
||||
metrics = compute_coverage(fields, verified)
|
||||
assert metrics.total_fields == 2
|
||||
assert metrics.supported_fields == 0
|
||||
assert metrics.coverage_rate == 0.0
|
||||
assert len(metrics.unsupported_claims) == 2
|
||||
assert metrics.unsupported_rate == 1.0
|
||||
|
||||
def test_empty_fields(self):
|
||||
"""No fields to verify means perfect coverage by definition."""
|
||||
metrics = compute_coverage([], {"s1", "s2"})
|
||||
assert metrics.total_fields == 0
|
||||
assert metrics.coverage_rate == 1.0
|
||||
assert metrics.unsupported_rate == 0.0
|
||||
|
||||
def test_field_with_no_evidence_ids(self):
|
||||
"""Field with empty evidence_ids is unsupported."""
|
||||
fields = [
|
||||
FieldEvidence(field_id="f1", field_name="revenue", evidence_ids=[]),
|
||||
]
|
||||
verified = {"s1", "s2"}
|
||||
metrics = compute_coverage(fields, verified)
|
||||
assert metrics.supported_fields == 0
|
||||
assert metrics.unsupported_claims == ["f1"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: VerificationMetrics (unsupported-claim and evidence-coverage rates)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVerificationMetrics:
|
||||
def test_single_report_all_pass(self):
|
||||
reports = [
|
||||
VerificationReport(
|
||||
total_candidates=10,
|
||||
verified=10,
|
||||
rejected=0,
|
||||
coverage_rate=1.0,
|
||||
rejection_breakdown={},
|
||||
)
|
||||
]
|
||||
metrics = compute_verification_metrics(reports)
|
||||
assert metrics.total_checked == 10
|
||||
assert metrics.passed_count == 10
|
||||
assert metrics.failed_count == 0
|
||||
assert metrics.evidence_coverage_rate == 1.0
|
||||
assert metrics.unsupported_claim_rate == 0.0
|
||||
assert metrics.per_reason_counts == {}
|
||||
|
||||
def test_single_report_some_failures(self):
|
||||
reports = [
|
||||
VerificationReport(
|
||||
total_candidates=10,
|
||||
verified=7,
|
||||
rejected=3,
|
||||
coverage_rate=0.7,
|
||||
rejection_breakdown={
|
||||
"entity_not_in_evidence": 2,
|
||||
"unsupported_claim": 1,
|
||||
},
|
||||
)
|
||||
]
|
||||
metrics = compute_verification_metrics(reports)
|
||||
assert metrics.total_checked == 10
|
||||
assert metrics.passed_count == 7
|
||||
assert metrics.failed_count == 3
|
||||
assert metrics.evidence_coverage_rate == 0.7
|
||||
assert metrics.unsupported_claim_rate == pytest.approx(0.1)
|
||||
assert metrics.per_reason_counts["entity_not_in_evidence"] == 2
|
||||
assert metrics.per_reason_counts["unsupported_claim"] == 1
|
||||
|
||||
def test_multiple_reports_aggregated(self):
|
||||
reports = [
|
||||
VerificationReport(
|
||||
total_candidates=5,
|
||||
verified=4,
|
||||
rejected=1,
|
||||
coverage_rate=0.8,
|
||||
rejection_breakdown={"invalid_offset": 1},
|
||||
),
|
||||
VerificationReport(
|
||||
total_candidates=10,
|
||||
verified=8,
|
||||
rejected=2,
|
||||
coverage_rate=0.8,
|
||||
rejection_breakdown={
|
||||
"numeric_inconsistency": 1,
|
||||
"unsupported_claim": 1,
|
||||
},
|
||||
),
|
||||
]
|
||||
metrics = compute_verification_metrics(reports)
|
||||
assert metrics.total_checked == 15
|
||||
assert metrics.passed_count == 12
|
||||
assert metrics.failed_count == 3
|
||||
assert metrics.evidence_coverage_rate == pytest.approx(12 / 15)
|
||||
assert metrics.unsupported_claim_rate == pytest.approx(1 / 15)
|
||||
assert metrics.per_reason_counts["invalid_offset"] == 1
|
||||
assert metrics.per_reason_counts["numeric_inconsistency"] == 1
|
||||
assert metrics.per_reason_counts["unsupported_claim"] == 1
|
||||
|
||||
|
||||
def test_empty_reports(self):
|
||||
metrics = compute_verification_metrics([])
|
||||
assert metrics.total_checked == 0
|
||||
assert metrics.passed_count == 0
|
||||
assert metrics.failed_count == 0
|
||||
assert metrics.evidence_coverage_rate == 1.0
|
||||
assert metrics.unsupported_claim_rate == 0.0
|
||||
|
||||
def test_metrics_is_frozen_dataclass(self):
|
||||
"""VerificationMetrics should be immutable."""
|
||||
metrics = compute_verification_metrics([])
|
||||
assert isinstance(metrics, VerificationMetrics)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Full verification report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullVerificationReport:
|
||||
def test_all_candidates_verified(self, source_text: str):
|
||||
verifier = EvidenceVerifier()
|
||||
spans = [
|
||||
EvidenceSpan(
|
||||
id="s1",
|
||||
start_char=0,
|
||||
end_char=10,
|
||||
text=source_text[0:10],
|
||||
),
|
||||
]
|
||||
candidates = [
|
||||
Candidate(
|
||||
candidate_type="entity",
|
||||
candidate_id="c1",
|
||||
candidate_data={"name": "Apple Inc."},
|
||||
evidence_ids=["s1"],
|
||||
literal_text="Apple Inc.",
|
||||
),
|
||||
]
|
||||
report = verifier.verify_all(candidates, spans, source_text)
|
||||
assert report.total_candidates == 1
|
||||
assert report.verified == 1
|
||||
assert report.rejected == 0
|
||||
assert report.coverage_rate == 1.0
|
||||
|
||||
def test_mixed_verification(self, source_text: str):
|
||||
verifier = EvidenceVerifier()
|
||||
spans = [
|
||||
EvidenceSpan(
|
||||
id="s1",
|
||||
start_char=0,
|
||||
end_char=10,
|
||||
text=source_text[0:10],
|
||||
),
|
||||
EvidenceSpan(
|
||||
id="s2",
|
||||
start_char=11,
|
||||
end_char=58,
|
||||
text=source_text[11:58],
|
||||
),
|
||||
]
|
||||
candidates = [
|
||||
Candidate(
|
||||
candidate_type="entity",
|
||||
candidate_id="c1",
|
||||
candidate_data={"name": "Apple"},
|
||||
evidence_ids=["s1"],
|
||||
literal_text="Apple Inc.",
|
||||
),
|
||||
Candidate(
|
||||
candidate_type="entity",
|
||||
candidate_id="c2",
|
||||
candidate_data={"name": "Microsoft"},
|
||||
evidence_ids=["s1", "s2"],
|
||||
literal_text="Microsoft",
|
||||
),
|
||||
]
|
||||
report = verifier.verify_all(candidates, spans, source_text)
|
||||
assert report.total_candidates == 2
|
||||
assert report.verified == 1
|
||||
assert report.rejected == 1
|
||||
assert report.coverage_rate == 0.5
|
||||
assert RejectionReason.ENTITY_NOT_IN_EVIDENCE.value in report.rejection_breakdown
|
||||
|
||||
|
||||
def test_invalid_span_cascades_to_candidate(self, source_text: str):
|
||||
"""If a candidate's only span is invalid, the candidate is rejected."""
|
||||
verifier = EvidenceVerifier()
|
||||
bad_span = EvidenceSpan(
|
||||
id="s-bad",
|
||||
start_char=0,
|
||||
end_char=10,
|
||||
text="WRONG TEXT", # Doesn't match source
|
||||
)
|
||||
candidates = [
|
||||
Candidate(
|
||||
candidate_type="entity",
|
||||
candidate_id="c1",
|
||||
candidate_data={"name": "test"},
|
||||
evidence_ids=["s-bad"],
|
||||
literal_text="Apple",
|
||||
),
|
||||
]
|
||||
report = verifier.verify_all(candidates, [bad_span], source_text)
|
||||
assert report.rejected == 1
|
||||
assert report.verified == 0
|
||||
|
||||
def test_numeric_candidate_in_full_report(self, source_text: str):
|
||||
verifier = EvidenceVerifier()
|
||||
spans = [
|
||||
EvidenceSpan(
|
||||
id="s1",
|
||||
start_char=11,
|
||||
end_char=58,
|
||||
text=source_text[11:58],
|
||||
),
|
||||
]
|
||||
candidates = [
|
||||
Candidate(
|
||||
candidate_type="fact",
|
||||
candidate_id="c1",
|
||||
candidate_data={"type": "revenue"},
|
||||
evidence_ids=["s1"],
|
||||
literal_text="$94.8 billion",
|
||||
normalized_value=94.8,
|
||||
),
|
||||
]
|
||||
report = verifier.verify_all(candidates, spans, source_text)
|
||||
assert report.verified == 1
|
||||
assert report.rejected == 0
|
||||
Reference in New Issue
Block a user