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,394 @@
|
||||
"""Unit tests for entity/ticker precision, recall, F1, and ambiguity accuracy.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.intelligence_pipeline_v3.evaluation.entity_metrics import (
|
||||
PRF1,
|
||||
AmbiguityResult,
|
||||
EntityMetricsResult,
|
||||
EntitySpan,
|
||||
MatchMode,
|
||||
TickerMention,
|
||||
TickerMetricsResult,
|
||||
compute_ambiguity_accuracy,
|
||||
compute_entity_metrics,
|
||||
compute_ticker_metrics,
|
||||
evaluate_entities,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _entity(
|
||||
text: str,
|
||||
entity_type: str,
|
||||
start: int,
|
||||
end: int,
|
||||
is_ambiguous: bool = False,
|
||||
) -> EntitySpan:
|
||||
return EntitySpan(
|
||||
text=text,
|
||||
entity_type=entity_type,
|
||||
start_char=start,
|
||||
end_char=end,
|
||||
is_ambiguous=is_ambiguous,
|
||||
)
|
||||
|
||||
|
||||
def _ticker(
|
||||
text: str,
|
||||
ticker: str,
|
||||
start: int,
|
||||
end: int,
|
||||
is_ambiguous: bool = False,
|
||||
) -> TickerMention:
|
||||
return TickerMention(
|
||||
text=text,
|
||||
ticker=ticker,
|
||||
start_char=start,
|
||||
end_char=end,
|
||||
is_ambiguous=is_ambiguous,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity Metrics - Strict Mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEntityMetricsStrict:
|
||||
def test_perfect_match(self) -> None:
|
||||
gold = [_entity("Apple", "company", 0, 5)]
|
||||
pred = [_entity("Apple", "company", 0, 5)]
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.strict)
|
||||
assert result.overall.precision == 1.0
|
||||
assert result.overall.recall == 1.0
|
||||
assert result.overall.f1 == 1.0
|
||||
|
||||
def test_no_predictions(self) -> None:
|
||||
gold = [_entity("Apple", "company", 0, 5)]
|
||||
result = compute_entity_metrics([], gold, MatchMode.strict)
|
||||
assert result.overall.precision == 1.0 # no false positives
|
||||
assert result.overall.recall == 0.0
|
||||
assert result.overall.f1 == 0.0
|
||||
|
||||
def test_no_gold(self) -> None:
|
||||
pred = [_entity("Apple", "company", 0, 5)]
|
||||
result = compute_entity_metrics(pred, [], MatchMode.strict)
|
||||
assert result.overall.precision == 0.0
|
||||
assert result.overall.recall == 1.0 # no false negatives
|
||||
assert result.overall.f1 == 0.0
|
||||
|
||||
def test_both_empty(self) -> None:
|
||||
result = compute_entity_metrics([], [], MatchMode.strict)
|
||||
assert result.overall.precision == 1.0
|
||||
assert result.overall.recall == 1.0
|
||||
assert result.overall.f1 == 1.0
|
||||
|
||||
def test_partial_match(self) -> None:
|
||||
gold = [
|
||||
_entity("Apple", "company", 0, 5),
|
||||
_entity("Tim Cook", "person", 10, 18),
|
||||
]
|
||||
pred = [
|
||||
_entity("Apple", "company", 0, 5),
|
||||
_entity("iPhone", "product", 20, 26),
|
||||
]
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.strict)
|
||||
# 1 TP out of 2 predicted -> precision = 0.5
|
||||
assert result.overall.precision == 0.5
|
||||
# 1 TP out of 2 gold -> recall = 0.5
|
||||
assert result.overall.recall == 0.5
|
||||
assert result.overall.f1 == 0.5
|
||||
|
||||
def test_wrong_type_no_match(self) -> None:
|
||||
gold = [_entity("Apple", "company", 0, 5)]
|
||||
pred = [_entity("Apple", "product", 0, 5)]
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.strict)
|
||||
assert result.overall.precision == 0.0
|
||||
assert result.overall.recall == 0.0
|
||||
|
||||
def test_off_by_one_no_strict_match(self) -> None:
|
||||
gold = [_entity("Apple", "company", 0, 5)]
|
||||
pred = [_entity("Apple", "company", 0, 6)] # end_char differs
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.strict)
|
||||
assert result.overall.precision == 0.0
|
||||
assert result.overall.recall == 0.0
|
||||
|
||||
def test_per_type_breakdown(self) -> None:
|
||||
gold = [
|
||||
_entity("Apple", "company", 0, 5),
|
||||
_entity("Google", "company", 10, 16),
|
||||
_entity("Tim Cook", "person", 20, 28),
|
||||
]
|
||||
pred = [
|
||||
_entity("Apple", "company", 0, 5),
|
||||
_entity("Tim Cook", "person", 20, 28),
|
||||
]
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.strict)
|
||||
assert result.per_type["company"].precision == 1.0
|
||||
assert result.per_type["company"].recall == 0.5
|
||||
assert result.per_type["person"].precision == 1.0
|
||||
assert result.per_type["person"].recall == 1.0
|
||||
assert result.per_type["person"].f1 == 1.0
|
||||
|
||||
def test_match_mode_in_result(self) -> None:
|
||||
result = compute_entity_metrics([], [], MatchMode.strict)
|
||||
assert result.match_mode == "strict"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity Metrics - Relaxed Mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEntityMetricsRelaxed:
|
||||
def test_overlapping_span_matches(self) -> None:
|
||||
gold = [_entity("Apple Inc.", "company", 0, 10)]
|
||||
pred = [_entity("Apple", "company", 0, 5)] # subset overlap
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.relaxed)
|
||||
assert result.overall.precision == 1.0
|
||||
assert result.overall.recall == 1.0
|
||||
assert result.overall.f1 == 1.0
|
||||
|
||||
def test_non_overlapping_no_match(self) -> None:
|
||||
gold = [_entity("Apple", "company", 0, 5)]
|
||||
pred = [_entity("Google", "company", 10, 16)]
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.relaxed)
|
||||
assert result.overall.precision == 0.0
|
||||
assert result.overall.recall == 0.0
|
||||
|
||||
def test_adjacent_spans_no_overlap(self) -> None:
|
||||
gold = [_entity("Apple", "company", 0, 5)]
|
||||
pred = [_entity("Inc", "company", 5, 8)] # adjacent, not overlapping
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.relaxed)
|
||||
assert result.overall.precision == 0.0
|
||||
assert result.overall.recall == 0.0
|
||||
|
||||
def test_partial_overlap_different_type(self) -> None:
|
||||
gold = [_entity("Apple", "company", 0, 5)]
|
||||
pred = [_entity("Apple", "product", 0, 5)]
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.relaxed)
|
||||
assert result.overall.precision == 0.0
|
||||
|
||||
def test_match_mode_in_result(self) -> None:
|
||||
result = compute_entity_metrics([], [], MatchMode.relaxed)
|
||||
assert result.match_mode == "relaxed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ticker Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTickerMetrics:
|
||||
def test_perfect_match_strict(self) -> None:
|
||||
gold = [_ticker("Apple Inc.", "AAPL", 0, 10)]
|
||||
pred = [_ticker("Apple Inc.", "AAPL", 0, 10)]
|
||||
result = compute_ticker_metrics(pred, gold, MatchMode.strict)
|
||||
assert result.overall.precision == 1.0
|
||||
assert result.overall.recall == 1.0
|
||||
assert result.overall.f1 == 1.0
|
||||
|
||||
def test_wrong_ticker_no_match(self) -> None:
|
||||
gold = [_ticker("Apple", "AAPL", 0, 5)]
|
||||
pred = [_ticker("Apple", "APLE", 0, 5)]
|
||||
result = compute_ticker_metrics(pred, gold, MatchMode.strict)
|
||||
assert result.overall.precision == 0.0
|
||||
assert result.overall.recall == 0.0
|
||||
|
||||
def test_relaxed_overlapping_ticker(self) -> None:
|
||||
gold = [_ticker("Apple Inc.", "AAPL", 0, 10)]
|
||||
pred = [_ticker("Apple", "AAPL", 0, 5)]
|
||||
result = compute_ticker_metrics(pred, gold, MatchMode.relaxed)
|
||||
assert result.overall.precision == 1.0
|
||||
assert result.overall.recall == 1.0
|
||||
|
||||
def test_multiple_tickers(self) -> None:
|
||||
gold = [
|
||||
_ticker("Apple", "AAPL", 0, 5),
|
||||
_ticker("Google", "GOOGL", 10, 16),
|
||||
_ticker("Microsoft", "MSFT", 20, 29),
|
||||
]
|
||||
pred = [
|
||||
_ticker("Apple", "AAPL", 0, 5),
|
||||
_ticker("Microsoft", "MSFT", 20, 29),
|
||||
]
|
||||
result = compute_ticker_metrics(pred, gold, MatchMode.strict)
|
||||
assert result.overall.precision == 1.0
|
||||
assert result.overall.recall == 2 / 3
|
||||
|
||||
def test_per_ticker_breakdown(self) -> None:
|
||||
gold = [
|
||||
_ticker("Apple", "AAPL", 0, 5),
|
||||
_ticker("Google", "GOOGL", 10, 16),
|
||||
]
|
||||
pred = [
|
||||
_ticker("Apple", "AAPL", 0, 5),
|
||||
]
|
||||
result = compute_ticker_metrics(pred, gold, MatchMode.strict)
|
||||
assert "AAPL" in result.per_type
|
||||
assert "GOOGL" in result.per_type
|
||||
assert result.per_type["AAPL"].f1 == 1.0
|
||||
assert result.per_type["GOOGL"].recall == 0.0
|
||||
|
||||
def test_empty_inputs(self) -> None:
|
||||
result = compute_ticker_metrics([], [], MatchMode.strict)
|
||||
assert result.overall.f1 == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ambiguity Accuracy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAmbiguityAccuracy:
|
||||
def test_perfect_ambiguity_detection(self) -> None:
|
||||
gold = [
|
||||
_entity("Apple", "company", 0, 5, is_ambiguous=True),
|
||||
_entity("Google", "company", 10, 16, is_ambiguous=False),
|
||||
]
|
||||
pred = [
|
||||
_entity("Apple", "company", 0, 5, is_ambiguous=True),
|
||||
_entity("Google", "company", 10, 16, is_ambiguous=False),
|
||||
]
|
||||
result = compute_ambiguity_accuracy(pred, gold)
|
||||
assert result.accuracy == 1.0
|
||||
assert result.true_positives == 1
|
||||
assert result.true_negatives == 1
|
||||
|
||||
def test_all_wrong(self) -> None:
|
||||
gold = [
|
||||
_entity("Apple", "company", 0, 5, is_ambiguous=True),
|
||||
_entity("Google", "company", 10, 16, is_ambiguous=False),
|
||||
]
|
||||
pred = [
|
||||
_entity("Apple", "company", 0, 5, is_ambiguous=False),
|
||||
_entity("Google", "company", 10, 16, is_ambiguous=True),
|
||||
]
|
||||
result = compute_ambiguity_accuracy(pred, gold)
|
||||
assert result.accuracy == 0.0
|
||||
assert result.false_negatives == 1
|
||||
assert result.false_positives == 1
|
||||
|
||||
def test_no_aligned_spans(self) -> None:
|
||||
gold = [_entity("Apple", "company", 0, 5, is_ambiguous=True)]
|
||||
pred = [_entity("Apple", "company", 10, 15, is_ambiguous=True)]
|
||||
result = compute_ambiguity_accuracy(pred, gold)
|
||||
assert result.support == 0
|
||||
assert result.accuracy == 1.0 # vacuously true
|
||||
|
||||
def test_mixed_results(self) -> None:
|
||||
gold = [
|
||||
_entity("Apple", "company", 0, 5, is_ambiguous=True),
|
||||
_entity("Google", "company", 10, 16, is_ambiguous=False),
|
||||
_entity("Tesla", "company", 20, 25, is_ambiguous=True),
|
||||
]
|
||||
pred = [
|
||||
_entity("Apple", "company", 0, 5, is_ambiguous=True), # TP
|
||||
_entity("Google", "company", 10, 16, is_ambiguous=True), # FP
|
||||
_entity("Tesla", "company", 20, 25, is_ambiguous=False), # FN
|
||||
]
|
||||
result = compute_ambiguity_accuracy(pred, gold)
|
||||
assert result.true_positives == 1
|
||||
assert result.false_positives == 1
|
||||
assert result.false_negatives == 1
|
||||
assert result.true_negatives == 0
|
||||
assert result.support == 3
|
||||
assert abs(result.accuracy - 1 / 3) < 1e-9
|
||||
|
||||
def test_ticker_mentions_supported(self) -> None:
|
||||
gold = [_ticker("Apple", "AAPL", 0, 5, is_ambiguous=True)]
|
||||
pred = [_ticker("Apple", "AAPL", 0, 5, is_ambiguous=True)]
|
||||
result = compute_ambiguity_accuracy(pred, gold)
|
||||
assert result.accuracy == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full Evaluation Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluateEntities:
|
||||
def test_full_evaluation(self) -> None:
|
||||
gold_entities = [
|
||||
_entity("Apple", "company", 0, 5),
|
||||
_entity("Tim Cook", "person", 10, 18),
|
||||
]
|
||||
pred_entities = [
|
||||
_entity("Apple", "company", 0, 5),
|
||||
_entity("Tim Cook", "person", 10, 18),
|
||||
]
|
||||
gold_tickers = [_ticker("Apple Inc.", "AAPL", 0, 10)]
|
||||
pred_tickers = [_ticker("Apple Inc.", "AAPL", 0, 10)]
|
||||
|
||||
report = evaluate_entities(
|
||||
pred_entities, gold_entities, pred_tickers, gold_tickers,
|
||||
mode=MatchMode.strict, document_count=1,
|
||||
)
|
||||
|
||||
assert isinstance(report.entity_metrics, EntityMetricsResult)
|
||||
assert isinstance(report.ticker_metrics, TickerMetricsResult)
|
||||
assert isinstance(report.ambiguity_accuracy, AmbiguityResult)
|
||||
assert report.document_count == 1
|
||||
assert report.entity_metrics.overall.f1 == 1.0
|
||||
assert report.ticker_metrics.overall.f1 == 1.0
|
||||
|
||||
def test_multiple_documents(self) -> None:
|
||||
# Simulating aggregated results from multiple documents
|
||||
gold_entities = [
|
||||
_entity("Apple", "company", 0, 5, is_ambiguous=True),
|
||||
_entity("Google", "company", 100, 106),
|
||||
]
|
||||
pred_entities = [
|
||||
_entity("Apple", "company", 0, 5, is_ambiguous=True),
|
||||
]
|
||||
gold_tickers = [
|
||||
_ticker("Apple", "AAPL", 0, 5),
|
||||
_ticker("Google", "GOOGL", 100, 106),
|
||||
]
|
||||
pred_tickers = [
|
||||
_ticker("Apple", "AAPL", 0, 5),
|
||||
]
|
||||
|
||||
report = evaluate_entities(
|
||||
pred_entities, gold_entities, pred_tickers, gold_tickers,
|
||||
mode=MatchMode.strict, document_count=2,
|
||||
)
|
||||
|
||||
assert report.document_count == 2
|
||||
assert report.entity_metrics.overall.recall == 0.5
|
||||
assert report.ticker_metrics.overall.recall == 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PRF1 Model validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPRF1Model:
|
||||
def test_valid_prf1(self) -> None:
|
||||
prf1 = PRF1(precision=0.8, recall=0.6, f1=0.686, support_predicted=10, support_gold=12)
|
||||
assert prf1.precision == 0.8
|
||||
assert prf1.recall == 0.6
|
||||
|
||||
def test_f1_harmonic_mean(self) -> None:
|
||||
"""F1 should be the harmonic mean when computed by the metric functions."""
|
||||
gold = [
|
||||
_entity("Apple", "company", 0, 5),
|
||||
_entity("Google", "company", 10, 16),
|
||||
_entity("Tesla", "company", 20, 25),
|
||||
]
|
||||
pred = [
|
||||
_entity("Apple", "company", 0, 5),
|
||||
_entity("Microsoft", "company", 30, 39),
|
||||
]
|
||||
result = compute_entity_metrics(pred, gold, MatchMode.strict)
|
||||
p = result.overall.precision
|
||||
r = result.overall.recall
|
||||
expected_f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0
|
||||
assert abs(result.overall.f1 - expected_f1) < 1e-9
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Unit tests for event and relation macro/micro F1 metrics.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.intelligence_pipeline_v3.evaluation.event_metrics import (
|
||||
EventMetricsResult,
|
||||
EventRelationEvaluationReport,
|
||||
GoldEvent,
|
||||
GoldRelation,
|
||||
PredictedEvent,
|
||||
PredictedRelation,
|
||||
RelationMetricsResult,
|
||||
compute_event_metrics,
|
||||
compute_relation_metrics,
|
||||
evaluate_events_and_relations,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.schemas.annotations import (
|
||||
EventClass,
|
||||
RelationType,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pred_event(
|
||||
event_class: EventClass,
|
||||
evidence_ids: list[str] | None = None,
|
||||
primary_company_ids: list[str] | None = None,
|
||||
confidence: float = 1.0,
|
||||
) -> PredictedEvent:
|
||||
return PredictedEvent(
|
||||
event_class=event_class,
|
||||
evidence_ids=evidence_ids or [],
|
||||
primary_company_ids=primary_company_ids or [],
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
|
||||
def _gold_event(
|
||||
event_class: EventClass,
|
||||
evidence_ids: list[str] | None = None,
|
||||
primary_company_ids: list[str] | None = None,
|
||||
) -> GoldEvent:
|
||||
return GoldEvent(
|
||||
event_class=event_class,
|
||||
evidence_ids=evidence_ids or [],
|
||||
primary_company_ids=primary_company_ids or [],
|
||||
)
|
||||
|
||||
|
||||
def _pred_relation(
|
||||
relation_type: RelationType,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
confidence: float = 1.0,
|
||||
) -> PredictedRelation:
|
||||
return PredictedRelation(
|
||||
relation_type=relation_type,
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
|
||||
def _gold_relation(
|
||||
relation_type: RelationType,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
) -> GoldRelation:
|
||||
return GoldRelation(
|
||||
relation_type=relation_type,
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event Metrics — Basic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEventMetricsBasic:
|
||||
def test_both_empty(self) -> None:
|
||||
result = compute_event_metrics([], [])
|
||||
# All per-class are vacuously 1.0 (no predictions, no gold)
|
||||
assert result.micro.precision == 1.0
|
||||
assert result.micro.recall == 1.0
|
||||
assert result.micro.f1 == 1.0
|
||||
assert result.macro_f1 == 1.0
|
||||
|
||||
def test_perfect_match_evidence(self) -> None:
|
||||
"""Events with same class and overlapping evidence match."""
|
||||
pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1", "e2"])]
|
||||
gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e2", "e3"])]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
assert result.micro.precision == 1.0
|
||||
assert result.micro.recall == 1.0
|
||||
assert result.micro.f1 == 1.0
|
||||
|
||||
def test_perfect_match_company(self) -> None:
|
||||
"""Events with same class and overlapping primary company match."""
|
||||
pred = [_pred_event(EventClass.MA_ANNOUNCEMENT, primary_company_ids=["c1"])]
|
||||
gold = [_gold_event(EventClass.MA_ANNOUNCEMENT, primary_company_ids=["c1", "c2"])]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
assert result.micro.precision == 1.0
|
||||
assert result.micro.recall == 1.0
|
||||
|
||||
def test_no_predictions(self) -> None:
|
||||
gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
result = compute_event_metrics([], gold)
|
||||
assert result.micro.recall == 0.0
|
||||
|
||||
def test_no_gold(self) -> None:
|
||||
pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
result = compute_event_metrics(pred, [])
|
||||
assert result.micro.precision == 0.0
|
||||
|
||||
def test_wrong_class_no_match(self) -> None:
|
||||
"""Different event_class means no match regardless of evidence."""
|
||||
pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
gold = [_gold_event(EventClass.EARNINGS_MISS, evidence_ids=["e1"])]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
assert result.micro.precision == 0.0
|
||||
assert result.micro.recall == 0.0
|
||||
|
||||
def test_no_overlap_no_match(self) -> None:
|
||||
"""Same class but no overlapping evidence or companies means no match."""
|
||||
pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"], primary_company_ids=["c1"])]
|
||||
gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e2"], primary_company_ids=["c2"])]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
assert result.micro.precision == 0.0
|
||||
assert result.micro.recall == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event Metrics — Per-Class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEventMetricsPerClass:
|
||||
def test_per_class_breakdown_all_13_classes(self) -> None:
|
||||
"""Result always contains all 13 event classes."""
|
||||
result = compute_event_metrics([], [])
|
||||
assert len(result.per_class) == 13
|
||||
for ec in EventClass:
|
||||
assert ec.value in result.per_class
|
||||
|
||||
def test_per_class_single_class(self) -> None:
|
||||
pred = [
|
||||
_pred_event(EventClass.PRODUCT_LAUNCH, evidence_ids=["e1"]),
|
||||
_pred_event(EventClass.PRODUCT_LAUNCH, evidence_ids=["e2"]),
|
||||
]
|
||||
gold = [
|
||||
_gold_event(EventClass.PRODUCT_LAUNCH, evidence_ids=["e1"]),
|
||||
_gold_event(EventClass.PRODUCT_LAUNCH, evidence_ids=["e3"]),
|
||||
]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
pl = result.per_class["product_launch"]
|
||||
# 1 TP (e1 match), 1 FP, 1 FN
|
||||
assert pl.precision == 0.5
|
||||
assert pl.recall == 0.5
|
||||
assert abs(pl.f1 - 0.5) < 1e-9
|
||||
|
||||
def test_per_class_mixed(self) -> None:
|
||||
pred = [
|
||||
_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
|
||||
_pred_event(EventClass.LEGAL_REGULATORY, primary_company_ids=["c1"]),
|
||||
]
|
||||
gold = [
|
||||
_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
|
||||
_gold_event(EventClass.LEGAL_REGULATORY, primary_company_ids=["c1"]),
|
||||
_gold_event(EventClass.MACRO_EVENT, evidence_ids=["e5"]),
|
||||
]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
assert result.per_class["earnings_beat"].f1 == 1.0
|
||||
assert result.per_class["legal_regulatory"].f1 == 1.0
|
||||
assert result.per_class["macro_event"].recall == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event Metrics — Macro vs Micro
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEventMetricsMacroMicro:
|
||||
def test_macro_averages_across_classes(self) -> None:
|
||||
"""Macro-F1 averages per-class F1, including classes with no data."""
|
||||
pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
# earnings_beat has F1=1.0, all other 12 classes have F1=1.0 (empty/empty)
|
||||
assert result.macro_f1 == 1.0
|
||||
|
||||
def test_macro_penalizes_missing_class(self) -> None:
|
||||
"""A class with only gold items drags macro-F1 down."""
|
||||
pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
gold = [
|
||||
_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
|
||||
_gold_event(EventClass.EARNINGS_MISS, evidence_ids=["e2"]),
|
||||
]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
# earnings_beat: F1=1.0, earnings_miss: recall=0 -> F1=0, rest: F1=1.0
|
||||
# macro = (1.0 + 0.0 + 11*1.0) / 13 = 12/13
|
||||
assert abs(result.macro_f1 - 12 / 13) < 1e-9
|
||||
|
||||
def test_micro_aggregates_tp_fp_fn(self) -> None:
|
||||
"""Micro-F1 sums TP/FP/FN across all classes."""
|
||||
pred = [
|
||||
_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]), # TP
|
||||
_pred_event(EventClass.RATING_CHANGE, evidence_ids=["e99"]), # FP
|
||||
]
|
||||
gold = [
|
||||
_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
|
||||
_gold_event(EventClass.MACRO_EVENT, evidence_ids=["e5"]), # FN
|
||||
]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
# TP=1, FP=1, FN=1 -> P=1/2, R=1/2, F1=1/2
|
||||
assert result.micro.support_predicted == 2
|
||||
assert result.micro.support_gold == 2
|
||||
assert result.micro.precision == 0.5
|
||||
assert result.micro.recall == 0.5
|
||||
assert abs(result.micro.f1 - 0.5) < 1e-9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relation Metrics — Basic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRelationMetricsBasic:
|
||||
def test_both_empty(self) -> None:
|
||||
result = compute_relation_metrics([], [])
|
||||
assert result.micro.f1 == 1.0
|
||||
assert result.macro_f1 == 1.0
|
||||
|
||||
def test_perfect_match(self) -> None:
|
||||
pred = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "comp1")]
|
||||
gold = [_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "comp1")]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
assert result.micro.f1 == 1.0
|
||||
|
||||
def test_wrong_type_no_match(self) -> None:
|
||||
pred = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "comp1")]
|
||||
gold = [_gold_relation(RelationType.INFERRED_EXPOSURE, "ev1", "comp1")]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
assert result.micro.precision == 0.0
|
||||
assert result.micro.recall == 0.0
|
||||
|
||||
def test_wrong_source_no_match(self) -> None:
|
||||
pred = [_pred_relation(RelationType.COMPETES_WITH, "c1", "c2")]
|
||||
gold = [_gold_relation(RelationType.COMPETES_WITH, "c3", "c2")]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
assert result.micro.precision == 0.0
|
||||
|
||||
def test_wrong_target_no_match(self) -> None:
|
||||
pred = [_pred_relation(RelationType.SUPPLIES, "c1", "c2")]
|
||||
gold = [_gold_relation(RelationType.SUPPLIES, "c1", "c3")]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
assert result.micro.precision == 0.0
|
||||
|
||||
def test_no_predictions(self) -> None:
|
||||
gold = [_gold_relation(RelationType.COMPETES_WITH, "c1", "c2")]
|
||||
result = compute_relation_metrics([], gold)
|
||||
assert result.micro.recall == 0.0
|
||||
|
||||
def test_no_gold(self) -> None:
|
||||
pred = [_pred_relation(RelationType.COMPETES_WITH, "c1", "c2")]
|
||||
result = compute_relation_metrics(pred, [])
|
||||
assert result.micro.precision == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relation Metrics — Per-Type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRelationMetricsPerType:
|
||||
def test_per_type_breakdown_all_4_types(self) -> None:
|
||||
"""Result always contains all 4 relation types."""
|
||||
result = compute_relation_metrics([], [])
|
||||
assert len(result.per_type) == 4
|
||||
for rt in RelationType:
|
||||
assert rt.value in result.per_type
|
||||
|
||||
def test_per_type_mixed(self) -> None:
|
||||
pred = [
|
||||
_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),
|
||||
_pred_relation(RelationType.COMPETES_WITH, "c1", "c2"),
|
||||
_pred_relation(RelationType.COMPETES_WITH, "c3", "c4"), # FP
|
||||
]
|
||||
gold = [
|
||||
_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),
|
||||
_gold_relation(RelationType.COMPETES_WITH, "c1", "c2"),
|
||||
_gold_relation(RelationType.SUPPLIES, "c5", "c6"), # FN
|
||||
]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
assert result.per_type["directly_affects"].f1 == 1.0
|
||||
assert result.per_type["competes_with"].precision == 0.5
|
||||
assert result.per_type["competes_with"].recall == 1.0
|
||||
assert result.per_type["supplies"].recall == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relation Metrics — Macro vs Micro
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRelationMetricsMacroMicro:
|
||||
def test_macro_averages_across_types(self) -> None:
|
||||
pred = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
|
||||
gold = [_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
# directly_affects: F1=1.0, other 3: F1=1.0 (empty)
|
||||
assert result.macro_f1 == 1.0
|
||||
|
||||
def test_macro_penalizes_missing_type(self) -> None:
|
||||
pred = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
|
||||
gold = [
|
||||
_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),
|
||||
_gold_relation(RelationType.SUPPLIES, "c5", "c6"),
|
||||
]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
# directly_affects: F1=1.0, supplies: recall=0 -> F1=0, other 2: F1=1.0
|
||||
# macro = (1.0 + 0.0 + 1.0 + 1.0) / 4 = 3/4
|
||||
assert abs(result.macro_f1 - 0.75) < 1e-9
|
||||
|
||||
def test_micro_aggregates(self) -> None:
|
||||
pred = [
|
||||
_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"), # TP
|
||||
_pred_relation(RelationType.COMPETES_WITH, "c1", "c99"), # FP
|
||||
]
|
||||
gold = [
|
||||
_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),
|
||||
_gold_relation(RelationType.INFERRED_EXPOSURE, "ev2", "c3"), # FN
|
||||
]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
# TP=1, FP=1, FN=1 -> P=1/2, R=1/2, F1=1/2
|
||||
assert result.micro.precision == 0.5
|
||||
assert result.micro.recall == 0.5
|
||||
assert abs(result.micro.f1 - 0.5) < 1e-9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Combined Evaluation Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluateEventsAndRelations:
|
||||
def test_full_report(self) -> None:
|
||||
pred_events = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
gold_events = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
pred_relations = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
|
||||
gold_relations = [_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
|
||||
|
||||
report = evaluate_events_and_relations(
|
||||
pred_events, gold_events, pred_relations, gold_relations,
|
||||
document_count=5,
|
||||
)
|
||||
|
||||
assert isinstance(report, EventRelationEvaluationReport)
|
||||
assert isinstance(report.event_metrics, EventMetricsResult)
|
||||
assert isinstance(report.relation_metrics, RelationMetricsResult)
|
||||
assert report.document_count == 5
|
||||
assert report.event_metrics.micro.f1 == 1.0
|
||||
assert report.relation_metrics.micro.f1 == 1.0
|
||||
|
||||
def test_report_with_failures(self) -> None:
|
||||
pred_events = [
|
||||
_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
|
||||
_pred_event(EventClass.SUPPLY_CHAIN, evidence_ids=["e99"]),
|
||||
]
|
||||
gold_events = [
|
||||
_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
|
||||
_gold_event(EventClass.MACRO_EVENT, primary_company_ids=["c7"]),
|
||||
]
|
||||
pred_relations = []
|
||||
gold_relations = [_gold_relation(RelationType.SUPPLIES, "c1", "c2")]
|
||||
|
||||
report = evaluate_events_and_relations(
|
||||
pred_events, gold_events, pred_relations, gold_relations,
|
||||
document_count=2,
|
||||
)
|
||||
|
||||
assert report.event_metrics.micro.precision == 0.5
|
||||
assert report.event_metrics.micro.recall == 0.5
|
||||
assert report.relation_metrics.micro.recall == 0.0
|
||||
assert report.document_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_event_match_requires_both_class_and_overlap(self) -> None:
|
||||
"""Same class but completely empty evidence and companies — no match."""
|
||||
pred = [_pred_event(EventClass.BUYBACK)]
|
||||
gold = [_gold_event(EventClass.BUYBACK)]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
# No evidence or companies to overlap -> no match
|
||||
assert result.per_class["buyback"].precision == 0.0
|
||||
|
||||
def test_multiple_events_greedy_matching(self) -> None:
|
||||
"""Greedy matching: first match consumes the gold item."""
|
||||
pred = [
|
||||
_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
|
||||
_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
|
||||
]
|
||||
gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
# 1 TP, 1 FP -> precision = 0.5, recall = 1.0
|
||||
assert result.per_class["earnings_beat"].precision == 0.5
|
||||
assert result.per_class["earnings_beat"].recall == 1.0
|
||||
|
||||
def test_relation_duplicates(self) -> None:
|
||||
"""Duplicate predictions can only match once."""
|
||||
pred = [
|
||||
_pred_relation(RelationType.COMPETES_WITH, "c1", "c2"),
|
||||
_pred_relation(RelationType.COMPETES_WITH, "c1", "c2"),
|
||||
]
|
||||
gold = [_gold_relation(RelationType.COMPETES_WITH, "c1", "c2")]
|
||||
result = compute_relation_metrics(pred, gold)
|
||||
assert result.per_type["competes_with"].precision == 0.5
|
||||
assert result.per_type["competes_with"].recall == 1.0
|
||||
|
||||
def test_event_confidence_does_not_affect_matching(self) -> None:
|
||||
"""Confidence is stored but doesn't affect match logic."""
|
||||
pred = [_pred_event(EventClass.DIVIDEND_CHANGE, evidence_ids=["e1"], confidence=0.1)]
|
||||
gold = [_gold_event(EventClass.DIVIDEND_CHANGE, evidence_ids=["e1"])]
|
||||
result = compute_event_metrics(pred, gold)
|
||||
assert result.per_class["dividend_change"].f1 == 1.0
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Unit tests for evidence offset validity, support rate, and related metrics.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.intelligence_pipeline_v3.evaluation.evidence_metrics import (
|
||||
EvidenceMetricsResult,
|
||||
EvidenceSpan,
|
||||
ExtractionResult,
|
||||
FieldType,
|
||||
compute_coverage_score,
|
||||
compute_offset_validity,
|
||||
compute_orphan_rate,
|
||||
compute_per_field_support,
|
||||
compute_support_rate,
|
||||
compute_unsupported_claim_rate,
|
||||
evaluate_evidence,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SOURCE_TEXT = "Apple reported revenue of $94.8 billion for Q3 2024. Tim Cook said growth was strong."
|
||||
|
||||
|
||||
def _span(span_id: str, text: str, start: int, end: int) -> EvidenceSpan:
|
||||
return EvidenceSpan(span_id=span_id, text=text, start_char=start, end_char=end)
|
||||
|
||||
|
||||
def _item(
|
||||
item_id: str,
|
||||
field_type: FieldType,
|
||||
evidence_ids: list[str] | None = None,
|
||||
required_fields: list[str] | None = None,
|
||||
supported_fields: list[str] | None = None,
|
||||
) -> ExtractionResult:
|
||||
return ExtractionResult(
|
||||
item_id=item_id,
|
||||
field_type=field_type,
|
||||
evidence_ids=evidence_ids or [],
|
||||
required_fields=required_fields or [],
|
||||
supported_fields=supported_fields or [],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Offset Validity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOffsetValidity:
|
||||
def test_all_valid(self) -> None:
|
||||
spans = [
|
||||
_span("s1", "Apple", 0, 5),
|
||||
_span("s2", "revenue", 15, 22),
|
||||
]
|
||||
rate, valid, total = compute_offset_validity(spans, SOURCE_TEXT)
|
||||
assert rate == 1.0
|
||||
assert valid == 2
|
||||
assert total == 2
|
||||
|
||||
def test_one_invalid_text_mismatch(self) -> None:
|
||||
spans = [
|
||||
_span("s1", "Apple", 0, 5),
|
||||
_span("s2", "WRONG", 15, 22), # text doesn't match source
|
||||
]
|
||||
rate, valid, total = compute_offset_validity(spans, SOURCE_TEXT)
|
||||
assert rate == 0.5
|
||||
assert valid == 1
|
||||
assert total == 2
|
||||
|
||||
def test_offset_out_of_bounds(self) -> None:
|
||||
spans = [
|
||||
_span("s1", "Apple", 0, 5),
|
||||
_span("s2", "text", 1000, 1004), # beyond source length
|
||||
]
|
||||
rate, valid, total = compute_offset_validity(spans, SOURCE_TEXT)
|
||||
assert rate == 0.5
|
||||
assert valid == 1
|
||||
assert total == 2
|
||||
|
||||
def test_negative_offsets(self) -> None:
|
||||
spans = [
|
||||
_span("s1", "Apple", -1, 5),
|
||||
]
|
||||
rate, valid, total = compute_offset_validity(spans, SOURCE_TEXT)
|
||||
assert rate == 0.0
|
||||
assert valid == 0
|
||||
assert total == 1
|
||||
|
||||
def test_start_greater_than_end(self) -> None:
|
||||
spans = [
|
||||
_span("s1", "Apple", 5, 0),
|
||||
]
|
||||
rate, valid, total = compute_offset_validity(spans, SOURCE_TEXT)
|
||||
assert rate == 0.0
|
||||
assert valid == 0
|
||||
assert total == 1
|
||||
|
||||
def test_empty_spans(self) -> None:
|
||||
rate, valid, total = compute_offset_validity([], SOURCE_TEXT)
|
||||
assert rate == 1.0
|
||||
assert valid == 0
|
||||
assert total == 0
|
||||
|
||||
def test_empty_text_span_at_boundary(self) -> None:
|
||||
# An empty span (start == end) should match empty string
|
||||
spans = [_span("s1", "", 5, 5)]
|
||||
rate, valid, total = compute_offset_validity(spans, SOURCE_TEXT)
|
||||
assert rate == 1.0
|
||||
assert valid == 1
|
||||
|
||||
def test_all_invalid(self) -> None:
|
||||
spans = [
|
||||
_span("s1", "WRONG", 0, 5),
|
||||
_span("s2", "ALSO_WRONG", 10, 20),
|
||||
]
|
||||
rate, valid, total = compute_offset_validity(spans, SOURCE_TEXT)
|
||||
assert rate == 0.0
|
||||
assert valid == 0
|
||||
assert total == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Support Rate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSupportRate:
|
||||
def test_all_supported(self) -> None:
|
||||
valid_ids = {"s1", "s2"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.fact, evidence_ids=["s2"]),
|
||||
]
|
||||
rate, supported, total = compute_support_rate(items, valid_ids)
|
||||
assert rate == 1.0
|
||||
assert supported == 2
|
||||
assert total == 2
|
||||
|
||||
def test_none_supported(self) -> None:
|
||||
valid_ids = {"s1"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s99"]),
|
||||
_item("i2", FieldType.fact, evidence_ids=["s100"]),
|
||||
]
|
||||
rate, supported, total = compute_support_rate(items, valid_ids)
|
||||
assert rate == 0.0
|
||||
assert supported == 0
|
||||
assert total == 2
|
||||
|
||||
def test_partial_support(self) -> None:
|
||||
valid_ids = {"s1"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.fact, evidence_ids=["s99"]),
|
||||
]
|
||||
rate, supported, total = compute_support_rate(items, valid_ids)
|
||||
assert rate == 0.5
|
||||
assert supported == 1
|
||||
assert total == 2
|
||||
|
||||
def test_item_with_multiple_evidence_one_valid(self) -> None:
|
||||
valid_ids = {"s2"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1", "s2"]),
|
||||
]
|
||||
rate, supported, total = compute_support_rate(items, valid_ids)
|
||||
assert rate == 1.0
|
||||
assert supported == 1
|
||||
|
||||
def test_empty_items(self) -> None:
|
||||
rate, supported, total = compute_support_rate([], {"s1"})
|
||||
assert rate == 1.0
|
||||
assert supported == 0
|
||||
assert total == 0
|
||||
|
||||
def test_item_with_no_evidence_ids(self) -> None:
|
||||
valid_ids = {"s1"}
|
||||
items = [_item("i1", FieldType.entity, evidence_ids=[])]
|
||||
rate, supported, total = compute_support_rate(items, valid_ids)
|
||||
assert rate == 0.0
|
||||
assert supported == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coverage Score
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCoverageScore:
|
||||
def test_full_coverage(self) -> None:
|
||||
items = [
|
||||
_item(
|
||||
"i1", FieldType.entity,
|
||||
required_fields=["name", "type"],
|
||||
supported_fields=["name", "type"],
|
||||
),
|
||||
]
|
||||
score = compute_coverage_score(items)
|
||||
assert score == 1.0
|
||||
|
||||
def test_partial_coverage(self) -> None:
|
||||
items = [
|
||||
_item(
|
||||
"i1", FieldType.entity,
|
||||
required_fields=["name", "type", "value"],
|
||||
supported_fields=["name"],
|
||||
),
|
||||
]
|
||||
score = compute_coverage_score(items)
|
||||
assert abs(score - 1 / 3) < 1e-9
|
||||
|
||||
def test_no_coverage(self) -> None:
|
||||
items = [
|
||||
_item(
|
||||
"i1", FieldType.entity,
|
||||
required_fields=["name", "type"],
|
||||
supported_fields=[],
|
||||
),
|
||||
]
|
||||
score = compute_coverage_score(items)
|
||||
assert score == 0.0
|
||||
|
||||
def test_no_required_fields_full_coverage(self) -> None:
|
||||
items = [
|
||||
_item("i1", FieldType.entity, required_fields=[], supported_fields=[]),
|
||||
]
|
||||
score = compute_coverage_score(items)
|
||||
assert score == 1.0
|
||||
|
||||
def test_average_across_items(self) -> None:
|
||||
items = [
|
||||
_item(
|
||||
"i1", FieldType.entity,
|
||||
required_fields=["name", "type"],
|
||||
supported_fields=["name", "type"],
|
||||
), # 1.0
|
||||
_item(
|
||||
"i2", FieldType.fact,
|
||||
required_fields=["value", "unit"],
|
||||
supported_fields=["value"],
|
||||
), # 0.5
|
||||
]
|
||||
score = compute_coverage_score(items)
|
||||
assert abs(score - 0.75) < 1e-9
|
||||
|
||||
def test_empty_items(self) -> None:
|
||||
score = compute_coverage_score([])
|
||||
assert score == 1.0
|
||||
|
||||
def test_supported_field_not_in_required(self) -> None:
|
||||
# Extra supported fields beyond required don't inflate the score
|
||||
items = [
|
||||
_item(
|
||||
"i1", FieldType.entity,
|
||||
required_fields=["name"],
|
||||
supported_fields=["name", "extra_field"],
|
||||
),
|
||||
]
|
||||
score = compute_coverage_score(items)
|
||||
assert score == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orphan Rate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrphanRate:
|
||||
def test_no_orphans(self) -> None:
|
||||
spans = [_span("s1", "Apple", 0, 5), _span("s2", "revenue", 15, 22)]
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.fact, evidence_ids=["s2"]),
|
||||
]
|
||||
rate, count = compute_orphan_rate(spans, items)
|
||||
assert rate == 0.0
|
||||
assert count == 0
|
||||
|
||||
def test_all_orphans(self) -> None:
|
||||
spans = [_span("s1", "Apple", 0, 5), _span("s2", "revenue", 15, 22)]
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s99"]),
|
||||
]
|
||||
rate, count = compute_orphan_rate(spans, items)
|
||||
assert rate == 1.0
|
||||
assert count == 2
|
||||
|
||||
def test_partial_orphans(self) -> None:
|
||||
spans = [
|
||||
_span("s1", "Apple", 0, 5),
|
||||
_span("s2", "revenue", 15, 22),
|
||||
_span("s3", "Q3 2024", 43, 50),
|
||||
]
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
]
|
||||
rate, count = compute_orphan_rate(spans, items)
|
||||
assert abs(rate - 2 / 3) < 1e-9
|
||||
assert count == 2
|
||||
|
||||
def test_empty_spans(self) -> None:
|
||||
items = [_item("i1", FieldType.entity, evidence_ids=["s1"])]
|
||||
rate, count = compute_orphan_rate([], items)
|
||||
assert rate == 0.0
|
||||
assert count == 0
|
||||
|
||||
def test_empty_items_all_orphans(self) -> None:
|
||||
spans = [_span("s1", "Apple", 0, 5)]
|
||||
rate, count = compute_orphan_rate(spans, [])
|
||||
assert rate == 1.0
|
||||
assert count == 1
|
||||
|
||||
def test_shared_evidence(self) -> None:
|
||||
# Multiple items referencing the same span - span is not orphan
|
||||
spans = [_span("s1", "Apple", 0, 5)]
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.sentiment, evidence_ids=["s1"]),
|
||||
]
|
||||
rate, count = compute_orphan_rate(spans, items)
|
||||
assert rate == 0.0
|
||||
assert count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-Field Support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPerFieldSupport:
|
||||
def test_all_types_supported(self) -> None:
|
||||
valid_ids = {"s1", "s2", "s3", "s4"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.event, evidence_ids=["s2"]),
|
||||
_item("i3", FieldType.fact, evidence_ids=["s3"]),
|
||||
_item("i4", FieldType.sentiment, evidence_ids=["s4"]),
|
||||
]
|
||||
result = compute_per_field_support(items, valid_ids)
|
||||
assert result["entity"] == 1.0
|
||||
assert result["event"] == 1.0
|
||||
assert result["fact"] == 1.0
|
||||
assert result["sentiment"] == 1.0
|
||||
|
||||
def test_mixed_support(self) -> None:
|
||||
valid_ids = {"s1"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.entity, evidence_ids=["s99"]),
|
||||
_item("i3", FieldType.fact, evidence_ids=["s1"]),
|
||||
]
|
||||
result = compute_per_field_support(items, valid_ids)
|
||||
assert result["entity"] == 0.5
|
||||
assert result["fact"] == 1.0
|
||||
|
||||
def test_empty_items(self) -> None:
|
||||
result = compute_per_field_support([], {"s1"})
|
||||
assert result == {}
|
||||
|
||||
def test_single_type(self) -> None:
|
||||
valid_ids = {"s1"}
|
||||
items = [
|
||||
_item("i1", FieldType.sentiment, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.sentiment, evidence_ids=["s1"]),
|
||||
]
|
||||
result = compute_per_field_support(items, valid_ids)
|
||||
assert len(result) == 1
|
||||
assert result["sentiment"] == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unsupported Claim Rate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnsupportedClaimRate:
|
||||
def test_all_supported(self) -> None:
|
||||
valid_ids = {"s1", "s2"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.fact, evidence_ids=["s2"]),
|
||||
]
|
||||
rate = compute_unsupported_claim_rate(items, valid_ids)
|
||||
assert rate == 0.0
|
||||
|
||||
def test_all_unsupported_no_evidence(self) -> None:
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=[]),
|
||||
_item("i2", FieldType.fact, evidence_ids=[]),
|
||||
]
|
||||
rate = compute_unsupported_claim_rate(items, {"s1"})
|
||||
assert rate == 1.0
|
||||
|
||||
def test_all_unsupported_invalid_evidence(self) -> None:
|
||||
valid_ids = {"s1"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s99"]),
|
||||
_item("i2", FieldType.fact, evidence_ids=["s100"]),
|
||||
]
|
||||
rate = compute_unsupported_claim_rate(items, valid_ids)
|
||||
assert rate == 1.0
|
||||
|
||||
def test_partial_unsupported(self) -> None:
|
||||
valid_ids = {"s1"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.fact, evidence_ids=[]),
|
||||
_item("i3", FieldType.event, evidence_ids=["s99"]),
|
||||
]
|
||||
rate = compute_unsupported_claim_rate(items, valid_ids)
|
||||
assert abs(rate - 2 / 3) < 1e-9
|
||||
|
||||
def test_empty_items(self) -> None:
|
||||
rate = compute_unsupported_claim_rate([], {"s1"})
|
||||
assert rate == 0.0
|
||||
|
||||
def test_mixed_evidence_one_valid(self) -> None:
|
||||
valid_ids = {"s2"}
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1", "s2"]),
|
||||
]
|
||||
rate = compute_unsupported_claim_rate(items, valid_ids)
|
||||
assert rate == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full Evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluateEvidence:
|
||||
def test_perfect_evaluation(self) -> None:
|
||||
source = "Apple reported revenue of $94.8 billion"
|
||||
spans = [
|
||||
_span("s1", "Apple", 0, 5),
|
||||
_span("s2", "$94.8 billion", 26, 39),
|
||||
]
|
||||
items = [
|
||||
_item(
|
||||
"i1", FieldType.entity, evidence_ids=["s1"],
|
||||
required_fields=["name"], supported_fields=["name"],
|
||||
),
|
||||
_item(
|
||||
"i2", FieldType.fact, evidence_ids=["s2"],
|
||||
required_fields=["value", "unit"], supported_fields=["value", "unit"],
|
||||
),
|
||||
]
|
||||
result = evaluate_evidence(spans, source, items)
|
||||
|
||||
assert isinstance(result, EvidenceMetricsResult)
|
||||
assert result.validity_rate == 1.0
|
||||
assert result.support_rate == 1.0
|
||||
assert result.coverage_score == 1.0
|
||||
assert result.orphan_rate == 0.0
|
||||
assert result.unsupported_claim_rate == 0.0
|
||||
assert result.total_spans == 2
|
||||
assert result.valid_spans == 2
|
||||
assert result.total_items == 2
|
||||
assert result.supported_items == 2
|
||||
assert result.orphan_spans == 0
|
||||
|
||||
def test_evaluation_with_invalid_spans(self) -> None:
|
||||
source = "Apple reported revenue"
|
||||
spans = [
|
||||
_span("s1", "Apple", 0, 5), # valid
|
||||
_span("s2", "WRONG", 6, 14), # invalid text
|
||||
]
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.fact, evidence_ids=["s2"]),
|
||||
]
|
||||
result = evaluate_evidence(spans, source, items)
|
||||
|
||||
assert result.validity_rate == 0.5
|
||||
assert result.support_rate == 0.5 # only i1 has valid evidence
|
||||
assert result.unsupported_claim_rate == 0.5
|
||||
|
||||
def test_evaluation_with_orphans(self) -> None:
|
||||
source = "Apple reported revenue of $94.8 billion"
|
||||
spans = [
|
||||
_span("s1", "Apple", 0, 5),
|
||||
_span("s2", "revenue", 15, 22),
|
||||
_span("s3", "$94.8 billion", 26, 39),
|
||||
]
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
]
|
||||
result = evaluate_evidence(spans, source, items)
|
||||
|
||||
assert result.validity_rate == 1.0
|
||||
assert result.support_rate == 1.0
|
||||
assert abs(result.orphan_rate - 2 / 3) < 1e-9
|
||||
assert result.orphan_spans == 2
|
||||
|
||||
def test_evaluation_empty_inputs(self) -> None:
|
||||
result = evaluate_evidence([], "", [])
|
||||
|
||||
assert result.validity_rate == 1.0
|
||||
assert result.support_rate == 1.0
|
||||
assert result.coverage_score == 1.0
|
||||
assert result.orphan_rate == 0.0
|
||||
assert result.unsupported_claim_rate == 0.0
|
||||
assert result.total_spans == 0
|
||||
assert result.total_items == 0
|
||||
|
||||
def test_per_field_support_in_report(self) -> None:
|
||||
source = "Apple reported strong growth in Q3"
|
||||
spans = [
|
||||
_span("s1", "Apple", 0, 5),
|
||||
_span("s2", "strong growth", 15, 28),
|
||||
]
|
||||
items = [
|
||||
_item("i1", FieldType.entity, evidence_ids=["s1"]),
|
||||
_item("i2", FieldType.sentiment, evidence_ids=["s2"]),
|
||||
_item("i3", FieldType.fact, evidence_ids=["s99"]), # unsupported
|
||||
]
|
||||
result = evaluate_evidence(spans, source, items)
|
||||
|
||||
assert result.per_field_support["entity"] == 1.0
|
||||
assert result.per_field_support["sentiment"] == 1.0
|
||||
assert result.per_field_support["fact"] == 0.0
|
||||
|
||||
def test_result_model_fields(self) -> None:
|
||||
result = EvidenceMetricsResult(
|
||||
validity_rate=0.9,
|
||||
support_rate=0.8,
|
||||
coverage_score=0.85,
|
||||
orphan_rate=0.1,
|
||||
per_field_support={"entity": 0.9, "fact": 0.7},
|
||||
unsupported_claim_rate=0.2,
|
||||
total_spans=10,
|
||||
valid_spans=9,
|
||||
total_items=5,
|
||||
supported_items=4,
|
||||
orphan_spans=1,
|
||||
)
|
||||
assert result.validity_rate == 0.9
|
||||
assert result.per_field_support["entity"] == 0.9
|
||||
assert result.orphan_spans == 1
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Unit tests for numeric exact/tolerance-aware matching metrics.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.intelligence_pipeline_v3.evaluation.numeric_metrics import (
|
||||
DEFAULT_TOLERANCE_PCT,
|
||||
AccuracyMetric,
|
||||
ErrorCategory,
|
||||
NumericEvaluationReport,
|
||||
NumericFact,
|
||||
ToleranceDistribution,
|
||||
evaluate_numeric_facts,
|
||||
match_numeric_fact,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fact(
|
||||
fact_type: str = "eps",
|
||||
predicate: str = "actual",
|
||||
literal_value: str = "$1.25",
|
||||
normalized_value: float | None = 1.25,
|
||||
unit: str | None = "USD",
|
||||
period: str | None = "Q1 2024",
|
||||
) -> NumericFact:
|
||||
return NumericFact(
|
||||
fact_type=fact_type,
|
||||
predicate=predicate,
|
||||
literal_value=literal_value,
|
||||
normalized_value=normalized_value,
|
||||
unit=unit,
|
||||
period=period,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single Fact Matching - Exact Match
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExactMatch:
|
||||
def test_identical_values(self) -> None:
|
||||
pred = _fact(normalized_value=1.25)
|
||||
gold = _fact(normalized_value=1.25)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.exact_match is True
|
||||
assert result.within_tolerance is True
|
||||
|
||||
def test_different_values(self) -> None:
|
||||
pred = _fact(normalized_value=1.30)
|
||||
gold = _fact(normalized_value=1.25)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.exact_match is False
|
||||
|
||||
def test_zero_values(self) -> None:
|
||||
pred = _fact(normalized_value=0.0)
|
||||
gold = _fact(normalized_value=0.0)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.exact_match is True
|
||||
|
||||
def test_negative_values(self) -> None:
|
||||
pred = _fact(normalized_value=-0.50)
|
||||
gold = _fact(normalized_value=-0.50)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.exact_match is True
|
||||
|
||||
def test_float_precision(self) -> None:
|
||||
"""Values that differ only by float rounding should be exact."""
|
||||
pred = _fact(normalized_value=0.1 + 0.2)
|
||||
gold = _fact(normalized_value=0.3)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
# 0.1 + 0.2 is ~0.30000000000000004, within 1e-9 of 0.3
|
||||
assert result.exact_match is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single Fact Matching - Tolerance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToleranceMatch:
|
||||
def test_within_5pct_default(self) -> None:
|
||||
# 5% of 100 = 5, so 104 is within tolerance
|
||||
pred = _fact(normalized_value=104.0)
|
||||
gold = _fact(normalized_value=100.0)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.within_tolerance is True
|
||||
assert result.exact_match is False
|
||||
|
||||
def test_exactly_at_5pct_boundary(self) -> None:
|
||||
# 5% of 100 = 5, so 105 is exactly at the boundary (inclusive)
|
||||
pred = _fact(normalized_value=105.0)
|
||||
gold = _fact(normalized_value=100.0)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.within_tolerance is True
|
||||
|
||||
def test_beyond_5pct(self) -> None:
|
||||
# 5% of 100 = 5, so 105.01 is beyond
|
||||
pred = _fact(normalized_value=105.01)
|
||||
gold = _fact(normalized_value=100.0)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.within_tolerance is False
|
||||
|
||||
def test_negative_tolerance(self) -> None:
|
||||
# 5% of 100 = 5, so 95 is within tolerance (below)
|
||||
pred = _fact(normalized_value=95.0)
|
||||
gold = _fact(normalized_value=100.0)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.within_tolerance is True
|
||||
|
||||
def test_custom_tolerance_1pct(self) -> None:
|
||||
pred = _fact(normalized_value=101.0)
|
||||
gold = _fact(normalized_value=100.0)
|
||||
result = match_numeric_fact(pred, gold, tolerance_pct=1.0)
|
||||
assert result.within_tolerance is True
|
||||
assert result.tolerance_pct == 1.0
|
||||
|
||||
def test_custom_tolerance_10pct(self) -> None:
|
||||
pred = _fact(normalized_value=109.0)
|
||||
gold = _fact(normalized_value=100.0)
|
||||
result = match_numeric_fact(pred, gold, tolerance_pct=10.0)
|
||||
assert result.within_tolerance is True
|
||||
|
||||
def test_zero_gold_value_tolerance(self) -> None:
|
||||
"""When gold is zero, tolerance uses absolute comparison."""
|
||||
pred = _fact(normalized_value=0.01)
|
||||
gold = _fact(normalized_value=0.0)
|
||||
result = match_numeric_fact(pred, gold, tolerance_pct=5.0)
|
||||
# 0.01 < 5/100 = 0.05
|
||||
assert result.within_tolerance is True
|
||||
|
||||
def test_zero_gold_value_beyond_tolerance(self) -> None:
|
||||
pred = _fact(normalized_value=0.1)
|
||||
gold = _fact(normalized_value=0.0)
|
||||
result = match_numeric_fact(pred, gold, tolerance_pct=5.0)
|
||||
# 0.1 >= 5/100 = 0.05
|
||||
assert result.within_tolerance is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit Consistency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnitConsistency:
|
||||
def test_same_units(self) -> None:
|
||||
pred = _fact(unit="USD")
|
||||
gold = _fact(unit="USD")
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.unit_consistent is True
|
||||
|
||||
def test_different_units(self) -> None:
|
||||
pred = _fact(unit="EUR")
|
||||
gold = _fact(unit="USD")
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.unit_consistent is False
|
||||
|
||||
def test_pred_missing_unit_gold_has_unit(self) -> None:
|
||||
pred = _fact(unit=None)
|
||||
gold = _fact(unit="USD")
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.unit_consistent is False
|
||||
|
||||
def test_gold_missing_unit(self) -> None:
|
||||
"""If gold has no unit, consistency is assumed."""
|
||||
pred = _fact(unit="USD")
|
||||
gold = _fact(unit=None)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.unit_consistent is True
|
||||
|
||||
def test_both_none_units(self) -> None:
|
||||
pred = _fact(unit=None)
|
||||
gold = _fact(unit=None)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.unit_consistent is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Period Match
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPeriodMatch:
|
||||
def test_same_period(self) -> None:
|
||||
pred = _fact(period="Q1 2024")
|
||||
gold = _fact(period="Q1 2024")
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.period_match is True
|
||||
|
||||
def test_different_period(self) -> None:
|
||||
pred = _fact(period="Q2 2024")
|
||||
gold = _fact(period="Q1 2024")
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.period_match is False
|
||||
|
||||
def test_pred_missing_period_gold_has_period(self) -> None:
|
||||
pred = _fact(period=None)
|
||||
gold = _fact(period="Q1 2024")
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.period_match is False
|
||||
|
||||
def test_gold_missing_period(self) -> None:
|
||||
"""If gold has no period, match is assumed."""
|
||||
pred = _fact(period="Q1 2024")
|
||||
gold = _fact(period=None)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.period_match is True
|
||||
|
||||
def test_both_none_periods(self) -> None:
|
||||
pred = _fact(period=None)
|
||||
gold = _fact(period=None)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.period_match is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorMetrics:
|
||||
def test_absolute_error(self) -> None:
|
||||
pred = _fact(normalized_value=1.30)
|
||||
gold = _fact(normalized_value=1.25)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.absolute_error is not None
|
||||
assert abs(result.absolute_error - 0.05) < 1e-9
|
||||
|
||||
def test_relative_error(self) -> None:
|
||||
pred = _fact(normalized_value=105.0)
|
||||
gold = _fact(normalized_value=100.0)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.relative_error_pct is not None
|
||||
assert abs(result.relative_error_pct - 5.0) < 1e-9
|
||||
|
||||
def test_relative_error_zero_gold(self) -> None:
|
||||
pred = _fact(normalized_value=1.0)
|
||||
gold = _fact(normalized_value=0.0)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.relative_error_pct is None
|
||||
|
||||
def test_none_pred_value(self) -> None:
|
||||
pred = _fact(normalized_value=None)
|
||||
gold = _fact(normalized_value=1.25)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.exact_match is False
|
||||
assert result.within_tolerance is False
|
||||
assert result.absolute_error is None
|
||||
assert result.relative_error_pct is None
|
||||
|
||||
def test_none_gold_value(self) -> None:
|
||||
pred = _fact(normalized_value=1.25)
|
||||
gold = _fact(normalized_value=None)
|
||||
result = match_numeric_fact(pred, gold)
|
||||
assert result.exact_match is False
|
||||
assert result.within_tolerance is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch Evaluation - Overall Accuracy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatchEvaluation:
|
||||
def test_perfect_match(self) -> None:
|
||||
gold = [
|
||||
_fact(fact_type="eps", predicate="actual", normalized_value=1.25),
|
||||
_fact(fact_type="revenue", predicate="actual", normalized_value=50.0e9),
|
||||
]
|
||||
pred = [
|
||||
_fact(fact_type="eps", predicate="actual", normalized_value=1.25),
|
||||
_fact(fact_type="revenue", predicate="actual", normalized_value=50.0e9),
|
||||
]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.exact_match_accuracy.accuracy == 1.0
|
||||
assert report.tolerance_accuracy.accuracy == 1.0
|
||||
|
||||
def test_empty_inputs(self) -> None:
|
||||
report = evaluate_numeric_facts([], [])
|
||||
assert report.exact_match_accuracy.accuracy == 1.0
|
||||
assert report.exact_match_accuracy.total == 0
|
||||
assert report.tolerance_accuracy.accuracy == 1.0
|
||||
|
||||
def test_no_matches(self) -> None:
|
||||
gold = [_fact(fact_type="eps", predicate="actual", normalized_value=1.25)]
|
||||
pred = [_fact(fact_type="eps", predicate="actual", normalized_value=2.00)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.exact_match_accuracy.accuracy == 0.0
|
||||
assert report.tolerance_accuracy.accuracy == 0.0
|
||||
|
||||
def test_tolerance_only_match(self) -> None:
|
||||
gold = [_fact(fact_type="eps", predicate="actual", normalized_value=100.0)]
|
||||
pred = [_fact(fact_type="eps", predicate="actual", normalized_value=103.0)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.exact_match_accuracy.accuracy == 0.0
|
||||
assert report.tolerance_accuracy.accuracy == 1.0
|
||||
|
||||
def test_unmatched_facts_not_aligned(self) -> None:
|
||||
"""Facts with different predicates don't align."""
|
||||
gold = [_fact(fact_type="eps", predicate="actual", normalized_value=1.25)]
|
||||
pred = [_fact(fact_type="eps", predicate="estimate", normalized_value=1.25)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
# No pairs aligned
|
||||
assert report.exact_match_accuracy.total == 0
|
||||
|
||||
def test_multiple_same_type_predicate(self) -> None:
|
||||
"""Multiple facts with same type and predicate align one-to-one."""
|
||||
gold = [
|
||||
_fact(fact_type="eps", predicate="actual", normalized_value=1.25),
|
||||
_fact(fact_type="eps", predicate="actual", normalized_value=2.50),
|
||||
]
|
||||
pred = [
|
||||
_fact(fact_type="eps", predicate="actual", normalized_value=1.25),
|
||||
_fact(fact_type="eps", predicate="actual", normalized_value=2.50),
|
||||
]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.exact_match_accuracy.matches == 2
|
||||
assert report.exact_match_accuracy.total == 2
|
||||
|
||||
def test_document_count(self) -> None:
|
||||
report = evaluate_numeric_facts([], [], document_count=5)
|
||||
assert report.document_count == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-Type Breakdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPerTypeBreakdown:
|
||||
def test_single_type(self) -> None:
|
||||
gold = [_fact(fact_type="eps", predicate="actual", normalized_value=1.25)]
|
||||
pred = [_fact(fact_type="eps", predicate="actual", normalized_value=1.25)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert "eps" in report.per_type_exact
|
||||
assert report.per_type_exact["eps"].accuracy == 1.0
|
||||
|
||||
def test_multiple_types(self) -> None:
|
||||
gold = [
|
||||
_fact(fact_type="eps", predicate="actual", normalized_value=1.25),
|
||||
_fact(fact_type="revenue", predicate="actual", normalized_value=50.0e9),
|
||||
_fact(fact_type="price_target", predicate="consensus", normalized_value=180.0),
|
||||
]
|
||||
pred = [
|
||||
_fact(fact_type="eps", predicate="actual", normalized_value=1.25),
|
||||
_fact(fact_type="revenue", predicate="actual", normalized_value=51.0e9),
|
||||
_fact(fact_type="price_target", predicate="consensus", normalized_value=200.0),
|
||||
]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.per_type_exact["eps"].accuracy == 1.0
|
||||
assert report.per_type_exact["revenue"].accuracy == 0.0
|
||||
# Revenue: 51e9 vs 50e9 = 2% off, within 5% tolerance
|
||||
assert report.per_type_tolerance["revenue"].accuracy == 1.0
|
||||
# Price target: 200 vs 180 = 11.1% off, beyond 5%
|
||||
assert report.per_type_tolerance["price_target"].accuracy == 0.0
|
||||
|
||||
def test_custom_tolerance_per_type(self) -> None:
|
||||
gold = [
|
||||
_fact(fact_type="guidance", predicate="low", normalized_value=5.0),
|
||||
]
|
||||
pred = [
|
||||
_fact(fact_type="guidance", predicate="low", normalized_value=5.4),
|
||||
]
|
||||
# 5.4 vs 5.0 = 8%, within 10% but not 5%
|
||||
report_5 = evaluate_numeric_facts(pred, gold, tolerance_pct=5.0)
|
||||
report_10 = evaluate_numeric_facts(pred, gold, tolerance_pct=10.0)
|
||||
assert report_5.per_type_tolerance["guidance"].accuracy == 0.0
|
||||
assert report_10.per_type_tolerance["guidance"].accuracy == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit Consistency Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnitConsistencyReport:
|
||||
def test_all_consistent(self) -> None:
|
||||
gold = [
|
||||
_fact(unit="USD", normalized_value=1.0),
|
||||
_fact(fact_type="revenue", predicate="actual", unit="USD", normalized_value=50.0),
|
||||
]
|
||||
pred = [
|
||||
_fact(unit="USD", normalized_value=1.0),
|
||||
_fact(fact_type="revenue", predicate="actual", unit="USD", normalized_value=50.0),
|
||||
]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.unit_consistency.accuracy == 1.0
|
||||
|
||||
def test_mixed_consistency(self) -> None:
|
||||
gold = [
|
||||
_fact(unit="USD", normalized_value=1.0),
|
||||
_fact(fact_type="revenue", predicate="actual", unit="USD", normalized_value=50.0),
|
||||
]
|
||||
pred = [
|
||||
_fact(unit="USD", normalized_value=1.0),
|
||||
_fact(fact_type="revenue", predicate="actual", unit="EUR", normalized_value=50.0),
|
||||
]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.unit_consistency.accuracy == 0.5
|
||||
assert report.unit_consistency.matches == 1
|
||||
assert report.unit_consistency.total == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Period Match Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPeriodMatchReport:
|
||||
def test_all_periods_match(self) -> None:
|
||||
gold = [_fact(period="Q1 2024", normalized_value=1.0)]
|
||||
pred = [_fact(period="Q1 2024", normalized_value=1.0)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.period_match.accuracy == 1.0
|
||||
|
||||
def test_period_mismatch(self) -> None:
|
||||
gold = [_fact(period="Q1 2024", normalized_value=1.0)]
|
||||
pred = [_fact(period="FY 2024", normalized_value=1.0)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.period_match.accuracy == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tolerance Distribution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToleranceDistribution:
|
||||
def test_exact_bucket(self) -> None:
|
||||
gold = [_fact(normalized_value=1.0)]
|
||||
pred = [_fact(normalized_value=1.0)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.tolerance_distribution.exact == 1
|
||||
|
||||
def test_within_1pct_bucket(self) -> None:
|
||||
gold = [_fact(normalized_value=100.0)]
|
||||
pred = [_fact(normalized_value=100.5)] # 0.5% off
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.tolerance_distribution.within_1pct == 1
|
||||
|
||||
def test_within_5pct_bucket(self) -> None:
|
||||
gold = [_fact(normalized_value=100.0)]
|
||||
pred = [_fact(normalized_value=103.0)] # 3% off
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.tolerance_distribution.within_5pct == 1
|
||||
|
||||
def test_within_10pct_bucket(self) -> None:
|
||||
gold = [_fact(normalized_value=100.0)]
|
||||
pred = [_fact(normalized_value=108.0)] # 8% off
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.tolerance_distribution.within_10pct == 1
|
||||
|
||||
def test_beyond_10pct_bucket(self) -> None:
|
||||
gold = [_fact(normalized_value=100.0)]
|
||||
pred = [_fact(normalized_value=115.0)] # 15% off
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.tolerance_distribution.beyond_10pct == 1
|
||||
|
||||
def test_not_comparable(self) -> None:
|
||||
gold = [_fact(normalized_value=None)]
|
||||
pred = [_fact(normalized_value=1.0)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.tolerance_distribution.not_comparable == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error Breakdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorBreakdown:
|
||||
def test_sign_error(self) -> None:
|
||||
gold = [_fact(normalized_value=1.0)]
|
||||
pred = [_fact(normalized_value=-1.0)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert ErrorCategory.sign_error.value in report.error_breakdown.counts
|
||||
assert report.error_breakdown.total_errors >= 1
|
||||
|
||||
def test_magnitude_error(self) -> None:
|
||||
gold = [_fact(normalized_value=1.0)]
|
||||
pred = [_fact(normalized_value=100.0)] # 100x off
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert ErrorCategory.magnitude_error.value in report.error_breakdown.counts
|
||||
|
||||
def test_parsing_failure(self) -> None:
|
||||
gold = [_fact(normalized_value=1.0)]
|
||||
pred = [_fact(normalized_value=None)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert ErrorCategory.parsing_failure.value in report.error_breakdown.counts
|
||||
|
||||
def test_no_errors_on_exact_match(self) -> None:
|
||||
gold = [_fact(normalized_value=1.0)]
|
||||
pred = [_fact(normalized_value=1.0)]
|
||||
report = evaluate_numeric_facts(pred, gold)
|
||||
assert report.error_breakdown.total_errors == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report Model Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReportModel:
|
||||
def test_report_fields(self) -> None:
|
||||
report = evaluate_numeric_facts([], [], tolerance_pct=7.5, document_count=3)
|
||||
assert isinstance(report, NumericEvaluationReport)
|
||||
assert report.tolerance_pct_used == 7.5
|
||||
assert report.document_count == 3
|
||||
assert isinstance(report.tolerance_distribution, ToleranceDistribution)
|
||||
assert isinstance(report.exact_match_accuracy, AccuracyMetric)
|
||||
|
||||
def test_default_tolerance(self) -> None:
|
||||
report = evaluate_numeric_facts([], [])
|
||||
assert report.tolerance_pct_used == DEFAULT_TOLERANCE_PCT
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Unit tests for the per-document-type and per-difficulty report generator.
|
||||
|
||||
Tests the DocumentResult model, generate_evaluation_report(), and
|
||||
format_report_markdown() function.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.intelligence_pipeline_v3.evaluation.entity_metrics import (
|
||||
EntitySpan,
|
||||
MatchMode,
|
||||
TickerMention,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.event_metrics import (
|
||||
GoldEvent,
|
||||
PredictedEvent,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.evidence_metrics import (
|
||||
EvidenceSpan,
|
||||
ExtractionResult,
|
||||
FieldType,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.numeric_metrics import NumericFact
|
||||
from services.intelligence_pipeline_v3.evaluation.report_generator import (
|
||||
Difficulty,
|
||||
DocumentResult,
|
||||
DocumentType,
|
||||
SafetyGateThresholds,
|
||||
format_report_markdown,
|
||||
generate_evaluation_report,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.resource_metrics import (
|
||||
StageTimingRecord,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.evaluation.sentiment_metrics import (
|
||||
SentimentLabel,
|
||||
SentimentPrediction,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.schemas.annotations import (
|
||||
EventClass,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_doc(
|
||||
doc_id: str = "doc-1",
|
||||
doc_type: DocumentType = DocumentType.article,
|
||||
difficulty: Difficulty = Difficulty.easy,
|
||||
*,
|
||||
with_entities: bool = False,
|
||||
with_events: bool = False,
|
||||
with_numeric: bool = False,
|
||||
with_evidence: bool = False,
|
||||
with_sentiment: bool = False,
|
||||
with_timings: bool = False,
|
||||
) -> DocumentResult:
|
||||
"""Create a DocumentResult with optional populated metric inputs."""
|
||||
kwargs: dict = {
|
||||
"document_id": doc_id,
|
||||
"document_type": doc_type,
|
||||
"difficulty": difficulty,
|
||||
}
|
||||
|
||||
if with_entities:
|
||||
kwargs["predicted_entities"] = [
|
||||
EntitySpan(text="Apple", entity_type="company", start_char=0, end_char=5),
|
||||
EntitySpan(text="iPhone", entity_type="product", start_char=10, end_char=16),
|
||||
]
|
||||
kwargs["gold_entities"] = [
|
||||
EntitySpan(text="Apple", entity_type="company", start_char=0, end_char=5),
|
||||
EntitySpan(text="iPhone", entity_type="product", start_char=10, end_char=16),
|
||||
]
|
||||
kwargs["predicted_tickers"] = [
|
||||
TickerMention(text="AAPL", ticker="AAPL", start_char=0, end_char=4),
|
||||
]
|
||||
kwargs["gold_tickers"] = [
|
||||
TickerMention(text="AAPL", ticker="AAPL", start_char=0, end_char=4),
|
||||
]
|
||||
|
||||
if with_events:
|
||||
kwargs["predicted_events"] = [
|
||||
PredictedEvent(
|
||||
event_class=EventClass.EARNINGS_BEAT,
|
||||
evidence_ids=["ev1"],
|
||||
primary_company_ids=["comp1"],
|
||||
),
|
||||
]
|
||||
kwargs["gold_events"] = [
|
||||
GoldEvent(
|
||||
event_class=EventClass.EARNINGS_BEAT,
|
||||
evidence_ids=["ev1"],
|
||||
primary_company_ids=["comp1"],
|
||||
),
|
||||
]
|
||||
|
||||
if with_numeric:
|
||||
kwargs["predicted_numeric_facts"] = [
|
||||
NumericFact(
|
||||
fact_type="eps",
|
||||
predicate="reported",
|
||||
literal_value="$1.50",
|
||||
normalized_value=1.50,
|
||||
unit="USD",
|
||||
),
|
||||
]
|
||||
kwargs["gold_numeric_facts"] = [
|
||||
NumericFact(
|
||||
fact_type="eps",
|
||||
predicate="reported",
|
||||
literal_value="$1.50",
|
||||
normalized_value=1.50,
|
||||
unit="USD",
|
||||
),
|
||||
]
|
||||
|
||||
if with_evidence:
|
||||
kwargs["source_text"] = "Apple reported earnings beat expectations."
|
||||
kwargs["evidence_spans"] = [
|
||||
EvidenceSpan(
|
||||
span_id="span-1",
|
||||
text="Apple reported earnings beat",
|
||||
start_char=0,
|
||||
end_char=28,
|
||||
),
|
||||
]
|
||||
kwargs["extraction_results"] = [
|
||||
ExtractionResult(
|
||||
item_id="item-1",
|
||||
field_type=FieldType.entity,
|
||||
evidence_ids=["span-1"],
|
||||
),
|
||||
]
|
||||
|
||||
if with_sentiment:
|
||||
kwargs["predicted_sentiments"] = [
|
||||
SentimentPrediction(
|
||||
company_entity_id="comp1",
|
||||
label=SentimentLabel.positive,
|
||||
positive_prob=0.8,
|
||||
negative_prob=0.1,
|
||||
neutral_prob=0.1,
|
||||
),
|
||||
]
|
||||
kwargs["gold_sentiments"] = [
|
||||
SentimentPrediction(
|
||||
company_entity_id="comp1",
|
||||
label=SentimentLabel.positive,
|
||||
positive_prob=0.9,
|
||||
negative_prob=0.05,
|
||||
neutral_prob=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
if with_timings:
|
||||
kwargs["stage_timings"] = [
|
||||
StageTimingRecord(
|
||||
document_id=doc_id,
|
||||
stage_name="extraction",
|
||||
start_time=100.0,
|
||||
end_time=101.5,
|
||||
input_tokens=500,
|
||||
output_tokens=200,
|
||||
cpu_seconds=1.2,
|
||||
gpu_seconds=0.3,
|
||||
gpu_memory_mb=4096.0,
|
||||
),
|
||||
StageTimingRecord(
|
||||
document_id=doc_id,
|
||||
stage_name="sentiment",
|
||||
start_time=101.5,
|
||||
end_time=102.0,
|
||||
input_tokens=200,
|
||||
output_tokens=50,
|
||||
cpu_seconds=0.4,
|
||||
gpu_seconds=0.0,
|
||||
),
|
||||
]
|
||||
|
||||
return DocumentResult(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — DocumentResult Model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDocumentResult:
|
||||
"""Tests for the DocumentResult model."""
|
||||
|
||||
def test_minimal_creation(self):
|
||||
doc = DocumentResult(
|
||||
document_id="test-1",
|
||||
document_type=DocumentType.article,
|
||||
difficulty=Difficulty.easy,
|
||||
)
|
||||
assert doc.document_id == "test-1"
|
||||
assert doc.document_type == DocumentType.article
|
||||
assert doc.difficulty == Difficulty.easy
|
||||
assert doc.predicted_entities == []
|
||||
assert doc.stage_timings == []
|
||||
|
||||
def test_all_document_types_valid(self):
|
||||
for dt in DocumentType:
|
||||
doc = DocumentResult(
|
||||
document_id="t",
|
||||
document_type=dt,
|
||||
difficulty=Difficulty.medium,
|
||||
)
|
||||
assert doc.document_type == dt
|
||||
|
||||
def test_all_difficulties_valid(self):
|
||||
for d in Difficulty:
|
||||
doc = DocumentResult(
|
||||
document_id="t",
|
||||
document_type=DocumentType.filing,
|
||||
difficulty=d,
|
||||
)
|
||||
assert doc.difficulty == d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — generate_evaluation_report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerateEvaluationReport:
|
||||
"""Tests for the generate_evaluation_report function."""
|
||||
|
||||
def test_empty_documents_list(self):
|
||||
report = generate_evaluation_report([])
|
||||
assert report.total_documents == 0
|
||||
assert report.overall.document_count == 0
|
||||
assert report.per_document_type == {}
|
||||
assert report.per_difficulty == {}
|
||||
assert report.safety_gate.passed is True
|
||||
|
||||
def test_single_document_overall(self):
|
||||
doc = _make_doc(
|
||||
with_entities=True,
|
||||
with_events=True,
|
||||
with_numeric=True,
|
||||
with_evidence=True,
|
||||
with_sentiment=True,
|
||||
with_timings=True,
|
||||
)
|
||||
report = generate_evaluation_report([doc])
|
||||
assert report.total_documents == 1
|
||||
assert report.overall.document_count == 1
|
||||
assert report.overall.entity_metrics is not None
|
||||
assert report.overall.event_metrics is not None
|
||||
assert report.overall.numeric_metrics is not None
|
||||
assert report.overall.evidence_metrics is not None
|
||||
assert report.overall.sentiment_metrics is not None
|
||||
assert report.overall.resource_metrics is not None
|
||||
|
||||
def test_groups_by_document_type(self):
|
||||
docs = [
|
||||
_make_doc("d1", DocumentType.article, Difficulty.easy, with_entities=True),
|
||||
_make_doc("d2", DocumentType.filing, Difficulty.easy, with_entities=True),
|
||||
_make_doc("d3", DocumentType.article, Difficulty.medium, with_entities=True),
|
||||
]
|
||||
report = generate_evaluation_report(docs)
|
||||
assert report.total_documents == 3
|
||||
assert "article" in report.per_document_type
|
||||
assert "filing" in report.per_document_type
|
||||
assert report.per_document_type["article"].document_count == 2
|
||||
assert report.per_document_type["filing"].document_count == 1
|
||||
|
||||
def test_groups_by_difficulty(self):
|
||||
docs = [
|
||||
_make_doc("d1", DocumentType.article, Difficulty.easy, with_entities=True),
|
||||
_make_doc("d2", DocumentType.article, Difficulty.hard, with_entities=True),
|
||||
_make_doc("d3", DocumentType.article, Difficulty.hard, with_entities=True),
|
||||
]
|
||||
report = generate_evaluation_report(docs)
|
||||
assert "easy" in report.per_difficulty
|
||||
assert "hard" in report.per_difficulty
|
||||
assert report.per_difficulty["easy"].document_count == 1
|
||||
assert report.per_difficulty["hard"].document_count == 2
|
||||
|
||||
def test_entity_metrics_perfect_match(self):
|
||||
doc = _make_doc(with_entities=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
entity_report = report.overall.entity_metrics
|
||||
assert entity_report is not None
|
||||
assert entity_report.entity_metrics.overall.f1 == 1.0
|
||||
assert entity_report.ticker_metrics.overall.f1 == 1.0
|
||||
|
||||
def test_event_metrics_perfect_match(self):
|
||||
doc = _make_doc(with_events=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
event_report = report.overall.event_metrics
|
||||
assert event_report is not None
|
||||
# The predicted event matches the gold event (same class, overlapping evidence)
|
||||
assert event_report.event_metrics.micro.f1 > 0.0
|
||||
|
||||
def test_numeric_metrics_exact_match(self):
|
||||
doc = _make_doc(with_numeric=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
nm = report.overall.numeric_metrics
|
||||
assert nm is not None
|
||||
assert nm.exact_match_accuracy.accuracy == 1.0
|
||||
|
||||
def test_evidence_metrics_valid_spans(self):
|
||||
doc = _make_doc(with_evidence=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
ev = report.overall.evidence_metrics
|
||||
assert ev is not None
|
||||
assert ev.validity_rate == 1.0
|
||||
assert ev.support_rate == 1.0
|
||||
|
||||
def test_sentiment_metrics_match(self):
|
||||
doc = _make_doc(with_sentiment=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
sm = report.overall.sentiment_metrics
|
||||
assert sm is not None
|
||||
assert sm.f1_metrics.macro_f1 > 0.0
|
||||
|
||||
def test_resource_metrics_present(self):
|
||||
doc = _make_doc(with_timings=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
rm = report.overall.resource_metrics
|
||||
assert rm is not None
|
||||
assert rm.document_count == 1
|
||||
assert rm.latency.p50 > 0.0
|
||||
assert rm.throughput.total_documents == 1
|
||||
|
||||
def test_empty_document_types_not_in_report(self):
|
||||
"""Document types with no documents should not appear in per_document_type."""
|
||||
docs = [_make_doc("d1", DocumentType.article, Difficulty.easy)]
|
||||
report = generate_evaluation_report(docs)
|
||||
assert "filing" not in report.per_document_type
|
||||
assert "transcript" not in report.per_document_type
|
||||
|
||||
def test_entity_match_mode_propagated(self):
|
||||
doc = _make_doc(with_entities=True)
|
||||
report_strict = generate_evaluation_report([doc], entity_match_mode=MatchMode.strict)
|
||||
report_relaxed = generate_evaluation_report([doc], entity_match_mode=MatchMode.relaxed)
|
||||
# Both should work; with perfect data, both should give same results
|
||||
assert report_strict.overall.entity_metrics is not None
|
||||
assert report_relaxed.overall.entity_metrics is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Safety Gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafetyGate:
|
||||
"""Tests for the safety gate evaluation."""
|
||||
|
||||
def test_all_pass_with_perfect_data(self):
|
||||
doc = _make_doc(
|
||||
with_entities=True,
|
||||
with_events=True,
|
||||
with_evidence=True,
|
||||
with_sentiment=True,
|
||||
)
|
||||
# Use relaxed ECE threshold since single-sample calibration can exceed defaults
|
||||
thresholds = SafetyGateThresholds(max_calibration_ece=0.3)
|
||||
report = generate_evaluation_report([doc], safety_thresholds=thresholds)
|
||||
assert report.safety_gate.passed is True
|
||||
assert all(report.safety_gate.checks.values())
|
||||
|
||||
def test_custom_thresholds_fail(self):
|
||||
"""Very high thresholds should cause failure on partial data."""
|
||||
# Create a doc with entity mismatch
|
||||
doc = DocumentResult(
|
||||
document_id="d1",
|
||||
document_type=DocumentType.article,
|
||||
difficulty=Difficulty.easy,
|
||||
predicted_entities=[
|
||||
EntitySpan(text="X", entity_type="company", start_char=0, end_char=1),
|
||||
],
|
||||
gold_entities=[
|
||||
EntitySpan(text="Y", entity_type="company", start_char=5, end_char=6),
|
||||
],
|
||||
)
|
||||
strict_thresholds = SafetyGateThresholds(min_entity_f1=0.9)
|
||||
report = generate_evaluation_report([doc], safety_thresholds=strict_thresholds)
|
||||
assert report.safety_gate.checks["entity_f1"] is False
|
||||
assert report.safety_gate.passed is False
|
||||
|
||||
def test_safety_gate_details_populated(self):
|
||||
doc = _make_doc(with_entities=True, with_sentiment=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
gate = report.safety_gate
|
||||
assert len(gate.checks) > 0
|
||||
assert len(gate.details) > 0
|
||||
# All details should be non-empty strings
|
||||
for detail in gate.details.values():
|
||||
assert isinstance(detail, str)
|
||||
assert len(detail) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — format_report_markdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatReportMarkdown:
|
||||
"""Tests for the markdown formatter."""
|
||||
|
||||
def test_empty_report_produces_valid_markdown(self):
|
||||
report = generate_evaluation_report([])
|
||||
md = format_report_markdown(report)
|
||||
assert "# Intelligence Pipeline v3" in md
|
||||
assert "Safety Gate" in md
|
||||
assert "Total documents evaluated:** 0" in md
|
||||
|
||||
def test_full_report_includes_all_sections(self):
|
||||
docs = [
|
||||
_make_doc(
|
||||
"d1", DocumentType.article, Difficulty.easy,
|
||||
with_entities=True, with_events=True,
|
||||
with_numeric=True, with_evidence=True,
|
||||
with_sentiment=True, with_timings=True,
|
||||
),
|
||||
_make_doc(
|
||||
"d2", DocumentType.filing, Difficulty.hard,
|
||||
with_entities=True, with_events=True,
|
||||
with_numeric=True, with_evidence=True,
|
||||
with_sentiment=True, with_timings=True,
|
||||
),
|
||||
]
|
||||
report = generate_evaluation_report(docs)
|
||||
md = format_report_markdown(report)
|
||||
|
||||
# Header
|
||||
assert "# Intelligence Pipeline v3 — Evaluation Report" in md
|
||||
# Safety gate
|
||||
assert "Safety Gate" in md
|
||||
assert "PASSED" in md or "FAILED" in md
|
||||
# Overall section
|
||||
assert "Overall Metrics" in md
|
||||
# Per type sections
|
||||
assert "Per Document Type" in md
|
||||
assert "article" in md
|
||||
assert "filing" in md
|
||||
# Per difficulty sections
|
||||
assert "Per Difficulty" in md
|
||||
assert "easy" in md
|
||||
assert "hard" in md
|
||||
# Metric sections
|
||||
assert "Entity Metrics" in md
|
||||
assert "Event & Relation Metrics" in md
|
||||
assert "Numeric Metrics" in md
|
||||
assert "Evidence Metrics" in md
|
||||
assert "Sentiment Metrics" in md
|
||||
assert "Resource Metrics" in md
|
||||
|
||||
def test_markdown_contains_numeric_values(self):
|
||||
doc = _make_doc(with_timings=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
md = format_report_markdown(report)
|
||||
# Should contain latency values
|
||||
assert "p50" in md or "Latency" in md
|
||||
assert "docs/min" in md
|
||||
|
||||
def test_safety_gate_pass_icon(self):
|
||||
doc = _make_doc(with_entities=True, with_sentiment=True)
|
||||
report = generate_evaluation_report([doc])
|
||||
md = format_report_markdown(report)
|
||||
assert "✅" in md
|
||||
|
||||
def test_safety_gate_fail_icon(self):
|
||||
doc = DocumentResult(
|
||||
document_id="d1",
|
||||
document_type=DocumentType.article,
|
||||
difficulty=Difficulty.easy,
|
||||
predicted_entities=[
|
||||
EntitySpan(text="X", entity_type="company", start_char=0, end_char=1),
|
||||
],
|
||||
gold_entities=[
|
||||
EntitySpan(text="Y", entity_type="company", start_char=5, end_char=6),
|
||||
],
|
||||
)
|
||||
thresholds = SafetyGateThresholds(min_entity_f1=0.9)
|
||||
report = generate_evaluation_report([doc], safety_thresholds=thresholds)
|
||||
md = format_report_markdown(report)
|
||||
assert "❌" in md
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Multi-document aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiDocumentAggregation:
|
||||
"""Tests for correct metric aggregation across multiple documents."""
|
||||
|
||||
def test_entities_aggregated_across_documents(self):
|
||||
"""Entity counts from multiple docs should sum in the overall report."""
|
||||
doc1 = DocumentResult(
|
||||
document_id="d1",
|
||||
document_type=DocumentType.article,
|
||||
difficulty=Difficulty.easy,
|
||||
predicted_entities=[
|
||||
EntitySpan(text="Apple", entity_type="company", start_char=0, end_char=5),
|
||||
],
|
||||
gold_entities=[
|
||||
EntitySpan(text="Apple", entity_type="company", start_char=0, end_char=5),
|
||||
],
|
||||
)
|
||||
doc2 = DocumentResult(
|
||||
document_id="d2",
|
||||
document_type=DocumentType.article,
|
||||
difficulty=Difficulty.medium,
|
||||
predicted_entities=[
|
||||
EntitySpan(text="Google", entity_type="company", start_char=0, end_char=6),
|
||||
],
|
||||
gold_entities=[
|
||||
EntitySpan(text="Google", entity_type="company", start_char=0, end_char=6),
|
||||
],
|
||||
)
|
||||
report = generate_evaluation_report([doc1, doc2])
|
||||
overall_entities = report.overall.entity_metrics
|
||||
assert overall_entities is not None
|
||||
assert overall_entities.entity_metrics.overall.support_gold == 2
|
||||
assert overall_entities.entity_metrics.overall.f1 == 1.0
|
||||
|
||||
def test_timings_aggregated_correctly(self):
|
||||
"""Resource metrics should include all documents' timings."""
|
||||
doc1 = _make_doc("d1", DocumentType.article, Difficulty.easy, with_timings=True)
|
||||
doc2 = _make_doc("d2", DocumentType.filing, Difficulty.hard, with_timings=True)
|
||||
report = generate_evaluation_report([doc1, doc2])
|
||||
rm = report.overall.resource_metrics
|
||||
assert rm is not None
|
||||
assert rm.document_count == 2
|
||||
assert rm.throughput.total_documents == 2
|
||||
@@ -0,0 +1,530 @@
|
||||
"""Unit tests for latency, throughput, token, CPU, GPU, and memory metrics.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from services.intelligence_pipeline_v3.evaluation.resource_metrics import (
|
||||
ResourceEvaluationReport,
|
||||
StageTimingRecord,
|
||||
compute_cpu_metrics,
|
||||
compute_efficiency_metrics,
|
||||
compute_gpu_metrics,
|
||||
compute_latency_metrics,
|
||||
compute_memory_metrics,
|
||||
compute_percentile,
|
||||
compute_throughput_metrics,
|
||||
compute_token_usage_metrics,
|
||||
evaluate_resources,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _record(
|
||||
document_id: str = "doc-1",
|
||||
stage_name: str = "extraction",
|
||||
start_time: float = 0.0,
|
||||
end_time: float = 1.0,
|
||||
input_tokens: int = 100,
|
||||
output_tokens: int = 50,
|
||||
gpu_memory_mb: float = 0.0,
|
||||
cpu_seconds: float = 0.5,
|
||||
gpu_seconds: float = 0.0,
|
||||
) -> StageTimingRecord:
|
||||
return StageTimingRecord(
|
||||
document_id=document_id,
|
||||
stage_name=stage_name,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
gpu_memory_mb=gpu_memory_mb,
|
||||
cpu_seconds=cpu_seconds,
|
||||
gpu_seconds=gpu_seconds,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Percentile Helper Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComputePercentile:
|
||||
def test_single_value(self) -> None:
|
||||
assert compute_percentile([5.0], 50.0) == 5.0
|
||||
assert compute_percentile([5.0], 0.0) == 5.0
|
||||
assert compute_percentile([5.0], 100.0) == 5.0
|
||||
|
||||
def test_two_values_median(self) -> None:
|
||||
result = compute_percentile([1.0, 3.0], 50.0)
|
||||
assert result == 2.0
|
||||
|
||||
def test_known_percentiles(self) -> None:
|
||||
values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
|
||||
p50 = compute_percentile(values, 50.0)
|
||||
assert abs(p50 - 5.5) < 1e-9
|
||||
|
||||
def test_unsorted_input(self) -> None:
|
||||
values = [5.0, 1.0, 3.0, 2.0, 4.0]
|
||||
p50 = compute_percentile(values, 50.0)
|
||||
assert p50 == 3.0
|
||||
|
||||
def test_p0_returns_min(self) -> None:
|
||||
values = [3.0, 1.0, 2.0]
|
||||
assert compute_percentile(values, 0.0) == 1.0
|
||||
|
||||
def test_p100_returns_max(self) -> None:
|
||||
values = [3.0, 1.0, 2.0]
|
||||
assert compute_percentile(values, 100.0) == 3.0
|
||||
|
||||
def test_empty_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
compute_percentile([], 50.0)
|
||||
|
||||
def test_out_of_range_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="between 0 and 100"):
|
||||
compute_percentile([1.0], 101.0)
|
||||
with pytest.raises(ValueError, match="between 0 and 100"):
|
||||
compute_percentile([1.0], -1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StageTimingRecord Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStageTimingRecord:
|
||||
def test_duration(self) -> None:
|
||||
r = _record(start_time=1.0, end_time=3.5)
|
||||
assert r.duration_seconds == 2.5
|
||||
|
||||
def test_total_tokens(self) -> None:
|
||||
r = _record(input_tokens=100, output_tokens=50)
|
||||
assert r.total_tokens == 150
|
||||
|
||||
def test_frozen(self) -> None:
|
||||
r = _record()
|
||||
with pytest.raises(Exception):
|
||||
r.document_id = "other" # type: ignore[misc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Latency Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLatencyMetrics:
|
||||
def test_empty_records(self) -> None:
|
||||
overall, per_stage = compute_latency_metrics([])
|
||||
assert overall.count == 0
|
||||
assert overall.mean == 0.0
|
||||
assert per_stage == []
|
||||
|
||||
def test_single_document_single_stage(self) -> None:
|
||||
records = [_record(start_time=0.0, end_time=2.0)]
|
||||
overall, per_stage = compute_latency_metrics(records)
|
||||
assert overall.count == 1
|
||||
assert overall.mean == 2.0
|
||||
assert overall.max == 2.0
|
||||
assert overall.p50 == 2.0
|
||||
assert len(per_stage) == 1
|
||||
assert per_stage[0].stage_name == "extraction"
|
||||
|
||||
def test_multiple_documents(self) -> None:
|
||||
records = [
|
||||
_record(document_id="doc-1", start_time=0.0, end_time=1.0),
|
||||
_record(document_id="doc-2", start_time=0.0, end_time=3.0),
|
||||
_record(document_id="doc-3", start_time=0.0, end_time=2.0),
|
||||
]
|
||||
overall, _ = compute_latency_metrics(records)
|
||||
assert overall.count == 3
|
||||
assert overall.mean == 2.0
|
||||
assert overall.max == 3.0
|
||||
assert overall.min == 1.0
|
||||
|
||||
def test_multi_stage_document(self) -> None:
|
||||
"""Document duration is from earliest start to latest end."""
|
||||
records = [
|
||||
_record(document_id="doc-1", stage_name="segmentation", start_time=0.0, end_time=1.0),
|
||||
_record(document_id="doc-1", stage_name="extraction", start_time=1.0, end_time=3.0),
|
||||
_record(document_id="doc-1", stage_name="sentiment", start_time=3.0, end_time=4.0),
|
||||
]
|
||||
overall, per_stage = compute_latency_metrics(records)
|
||||
# Total document duration: 0 -> 4 = 4 seconds
|
||||
assert overall.count == 1
|
||||
assert overall.mean == 4.0
|
||||
assert len(per_stage) == 3
|
||||
|
||||
def test_per_stage_breakdown(self) -> None:
|
||||
records = [
|
||||
_record(document_id="doc-1", stage_name="extraction", start_time=0.0, end_time=2.0),
|
||||
_record(document_id="doc-2", stage_name="extraction", start_time=0.0, end_time=4.0),
|
||||
_record(document_id="doc-1", stage_name="sentiment", start_time=2.0, end_time=2.5),
|
||||
]
|
||||
_, per_stage = compute_latency_metrics(records)
|
||||
stage_map = {s.stage_name: s for s in per_stage}
|
||||
assert stage_map["extraction"].invocation_count == 2
|
||||
assert stage_map["extraction"].latency.mean == 3.0
|
||||
assert stage_map["sentiment"].invocation_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Throughput Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThroughputMetrics:
|
||||
def test_empty_records(self) -> None:
|
||||
result = compute_throughput_metrics([])
|
||||
assert result.total_documents == 0
|
||||
assert result.documents_per_minute == 0.0
|
||||
|
||||
def test_single_document(self) -> None:
|
||||
records = [_record(start_time=0.0, end_time=60.0)]
|
||||
result = compute_throughput_metrics(records)
|
||||
assert result.total_documents == 1
|
||||
assert result.total_wall_seconds == 60.0
|
||||
assert abs(result.documents_per_minute - 1.0) < 1e-9
|
||||
assert abs(result.documents_per_hour - 60.0) < 1e-9
|
||||
|
||||
def test_multiple_documents(self) -> None:
|
||||
records = [
|
||||
_record(document_id="doc-1", start_time=0.0, end_time=10.0),
|
||||
_record(document_id="doc-2", start_time=5.0, end_time=15.0),
|
||||
_record(document_id="doc-3", start_time=10.0, end_time=30.0),
|
||||
]
|
||||
result = compute_throughput_metrics(records)
|
||||
assert result.total_documents == 3
|
||||
assert result.total_wall_seconds == 30.0
|
||||
# 3 docs / 30 seconds = 0.1 docs/sec = 6 docs/min
|
||||
assert abs(result.documents_per_minute - 6.0) < 1e-9
|
||||
assert abs(result.documents_per_hour - 360.0) < 1e-9
|
||||
|
||||
def test_zero_duration(self) -> None:
|
||||
"""All records start and end at same time."""
|
||||
records = [_record(start_time=5.0, end_time=5.0)]
|
||||
result = compute_throughput_metrics(records)
|
||||
assert result.documents_per_minute == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token Usage Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenUsageMetrics:
|
||||
def test_empty_records(self) -> None:
|
||||
result = compute_token_usage_metrics([])
|
||||
assert result.total_tokens == 0
|
||||
assert result.per_stage == {}
|
||||
|
||||
def test_single_record(self) -> None:
|
||||
records = [_record(input_tokens=200, output_tokens=80)]
|
||||
result = compute_token_usage_metrics(records)
|
||||
assert result.total_input_tokens == 200
|
||||
assert result.total_output_tokens == 80
|
||||
assert result.total_tokens == 280
|
||||
assert result.mean_input_tokens_per_document == 200.0
|
||||
assert result.mean_output_tokens_per_document == 80.0
|
||||
assert result.mean_total_tokens_per_document == 280.0
|
||||
|
||||
def test_multiple_documents_and_stages(self) -> None:
|
||||
records = [
|
||||
_record(document_id="doc-1", stage_name="extraction", input_tokens=100, output_tokens=50),
|
||||
_record(document_id="doc-1", stage_name="sentiment", input_tokens=50, output_tokens=20),
|
||||
_record(document_id="doc-2", stage_name="extraction", input_tokens=150, output_tokens=60),
|
||||
]
|
||||
result = compute_token_usage_metrics(records)
|
||||
assert result.total_input_tokens == 300
|
||||
assert result.total_output_tokens == 130
|
||||
assert result.total_tokens == 430
|
||||
# 2 documents
|
||||
assert result.mean_input_tokens_per_document == 150.0
|
||||
assert result.mean_output_tokens_per_document == 65.0
|
||||
|
||||
def test_per_stage_breakdown(self) -> None:
|
||||
records = [
|
||||
_record(document_id="doc-1", stage_name="extraction", input_tokens=100, output_tokens=50),
|
||||
_record(document_id="doc-2", stage_name="extraction", input_tokens=200, output_tokens=100),
|
||||
_record(document_id="doc-1", stage_name="sentiment", input_tokens=30, output_tokens=10),
|
||||
]
|
||||
result = compute_token_usage_metrics(records)
|
||||
assert "extraction" in result.per_stage
|
||||
assert "sentiment" in result.per_stage
|
||||
ext = result.per_stage["extraction"]
|
||||
assert ext.count == 2
|
||||
assert ext.total_input_tokens == 300
|
||||
assert ext.mean_input_tokens == 150.0
|
||||
sent = result.per_stage["sentiment"]
|
||||
assert sent.count == 1
|
||||
assert sent.total_tokens == 40
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPU Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCpuMetrics:
|
||||
def test_empty_records(self) -> None:
|
||||
result = compute_cpu_metrics([])
|
||||
assert result.total_cpu_seconds == 0.0
|
||||
|
||||
def test_single_record(self) -> None:
|
||||
records = [_record(cpu_seconds=2.5)]
|
||||
result = compute_cpu_metrics(records)
|
||||
assert result.total_cpu_seconds == 2.5
|
||||
assert result.mean_cpu_seconds_per_document == 2.5
|
||||
assert result.peak_cpu_seconds == 2.5
|
||||
|
||||
def test_multiple_documents(self) -> None:
|
||||
records = [
|
||||
_record(document_id="doc-1", stage_name="extraction", cpu_seconds=1.0),
|
||||
_record(document_id="doc-1", stage_name="sentiment", cpu_seconds=0.5),
|
||||
_record(document_id="doc-2", stage_name="extraction", cpu_seconds=3.0),
|
||||
]
|
||||
result = compute_cpu_metrics(records)
|
||||
assert result.total_cpu_seconds == 4.5
|
||||
# doc-1: 1.5, doc-2: 3.0
|
||||
assert result.mean_cpu_seconds_per_document == 2.25
|
||||
assert result.peak_cpu_seconds == 3.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGpuMetrics:
|
||||
def test_empty_records(self) -> None:
|
||||
result = compute_gpu_metrics([])
|
||||
assert result.total_gpu_seconds == 0.0
|
||||
assert result.gpu_utilization_percent == 0.0
|
||||
|
||||
def test_no_gpu_usage(self) -> None:
|
||||
records = [_record(gpu_seconds=0.0, gpu_memory_mb=0.0)]
|
||||
result = compute_gpu_metrics(records)
|
||||
assert result.total_gpu_seconds == 0.0
|
||||
assert result.peak_gpu_memory_mb == 0.0
|
||||
assert result.mean_gpu_memory_mb == 0.0
|
||||
|
||||
def test_with_gpu_usage(self) -> None:
|
||||
records = [
|
||||
_record(
|
||||
document_id="doc-1",
|
||||
start_time=0.0, end_time=10.0,
|
||||
gpu_seconds=5.0, gpu_memory_mb=4096.0,
|
||||
),
|
||||
_record(
|
||||
document_id="doc-2",
|
||||
start_time=10.0, end_time=20.0,
|
||||
gpu_seconds=3.0, gpu_memory_mb=8192.0,
|
||||
),
|
||||
]
|
||||
result = compute_gpu_metrics(records)
|
||||
assert result.total_gpu_seconds == 8.0
|
||||
assert result.mean_gpu_seconds_per_document == 4.0
|
||||
assert result.peak_gpu_memory_mb == 8192.0
|
||||
assert result.mean_gpu_memory_mb == 6144.0
|
||||
# 8 gpu-seconds / 20 wall-seconds = 40%
|
||||
assert abs(result.gpu_utilization_percent - 40.0) < 1e-9
|
||||
|
||||
def test_utilization_capped_at_100(self) -> None:
|
||||
"""Parallel GPU stages could sum to more than wall time."""
|
||||
records = [
|
||||
_record(
|
||||
document_id="doc-1",
|
||||
start_time=0.0, end_time=1.0,
|
||||
gpu_seconds=5.0, gpu_memory_mb=1000.0,
|
||||
),
|
||||
]
|
||||
result = compute_gpu_metrics(records)
|
||||
assert result.gpu_utilization_percent == 100.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMemoryMetrics:
|
||||
def test_empty_records_no_samples(self) -> None:
|
||||
result = compute_memory_metrics([])
|
||||
assert result.peak_rss_memory_mb == 0.0
|
||||
assert result.mean_working_set_mb == 0.0
|
||||
|
||||
def test_with_rss_samples(self) -> None:
|
||||
records = [_record(gpu_memory_mb=5000.0)]
|
||||
# RSS samples take precedence
|
||||
result = compute_memory_metrics(records, rss_samples_mb=[100.0, 200.0, 300.0])
|
||||
assert result.peak_rss_memory_mb == 300.0
|
||||
assert result.mean_working_set_mb == 200.0
|
||||
|
||||
def test_fallback_to_gpu_memory(self) -> None:
|
||||
records = [
|
||||
_record(gpu_memory_mb=4096.0),
|
||||
_record(gpu_memory_mb=8192.0),
|
||||
]
|
||||
result = compute_memory_metrics(records)
|
||||
assert result.peak_rss_memory_mb == 8192.0
|
||||
assert result.mean_working_set_mb == 6144.0
|
||||
|
||||
def test_zero_gpu_memory_treated_as_no_data(self) -> None:
|
||||
records = [_record(gpu_memory_mb=0.0)]
|
||||
result = compute_memory_metrics(records)
|
||||
assert result.peak_rss_memory_mb == 0.0
|
||||
assert result.mean_working_set_mb == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Efficiency Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEfficiencyMetrics:
|
||||
def test_empty_records(self) -> None:
|
||||
result = compute_efficiency_metrics([])
|
||||
assert result.tokens_per_second == 0.0
|
||||
assert result.documents_per_gpu_second == 0.0
|
||||
assert result.fast_path_fraction == 0.0
|
||||
assert result.adjudication_fraction == 0.0
|
||||
|
||||
def test_tokens_per_second(self) -> None:
|
||||
records = [
|
||||
_record(
|
||||
start_time=0.0, end_time=10.0,
|
||||
input_tokens=500, output_tokens=500,
|
||||
),
|
||||
]
|
||||
result = compute_efficiency_metrics(records)
|
||||
# 1000 tokens / 10 seconds = 100 tokens/sec
|
||||
assert abs(result.tokens_per_second - 100.0) < 1e-9
|
||||
|
||||
def test_documents_per_gpu_second(self) -> None:
|
||||
records = [
|
||||
_record(document_id="doc-1", gpu_seconds=2.0),
|
||||
_record(document_id="doc-2", gpu_seconds=3.0),
|
||||
]
|
||||
result = compute_efficiency_metrics(records)
|
||||
# 2 docs / 5 gpu-seconds = 0.4 docs/gpu-sec
|
||||
assert abs(result.documents_per_gpu_second - 0.4) < 1e-9
|
||||
|
||||
def test_no_gpu_usage_infinite_docs(self) -> None:
|
||||
"""When no GPU time, documents_per_gpu_second should be 0 (avoid division by zero)."""
|
||||
records = [_record(gpu_seconds=0.0)]
|
||||
result = compute_efficiency_metrics(records)
|
||||
assert result.documents_per_gpu_second == 0.0
|
||||
|
||||
def test_fast_path_vs_adjudication_split(self) -> None:
|
||||
records = [
|
||||
_record(stage_name="extraction", cpu_seconds=2.0, gpu_seconds=0.0),
|
||||
_record(stage_name="sentiment", cpu_seconds=1.0, gpu_seconds=0.0),
|
||||
_record(stage_name="adjudication", cpu_seconds=0.5, gpu_seconds=3.0),
|
||||
]
|
||||
result = compute_efficiency_metrics(records)
|
||||
assert result.fast_path_cpu_seconds == 3.0
|
||||
assert result.adjudication_cpu_seconds == 0.5
|
||||
assert result.fast_path_gpu_seconds == 0.0
|
||||
assert result.adjudication_gpu_seconds == 3.0
|
||||
# Fast: 3.0, Adj: 3.5, Total: 6.5
|
||||
assert abs(result.fast_path_fraction - 3.0 / 6.5) < 1e-9
|
||||
assert abs(result.adjudication_fraction - 3.5 / 6.5) < 1e-9
|
||||
|
||||
def test_adjudication_stage_detection(self) -> None:
|
||||
"""Various adjudication stage name patterns should be detected."""
|
||||
records = [
|
||||
_record(stage_name="9b_adjudication", cpu_seconds=1.0, gpu_seconds=1.0),
|
||||
_record(stage_name="semantic_adjudication", cpu_seconds=1.0, gpu_seconds=1.0),
|
||||
_record(stage_name="my_adjudicator_stage", cpu_seconds=1.0, gpu_seconds=1.0),
|
||||
]
|
||||
result = compute_efficiency_metrics(records)
|
||||
assert result.adjudication_cpu_seconds == 3.0
|
||||
assert result.adjudication_gpu_seconds == 3.0
|
||||
assert result.fast_path_cpu_seconds == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full Evaluation Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluateResources:
|
||||
def test_empty_records(self) -> None:
|
||||
report = evaluate_resources([])
|
||||
assert report.document_count == 0
|
||||
assert report.latency.count == 0
|
||||
assert report.throughput.total_documents == 0
|
||||
|
||||
def test_complete_report(self) -> None:
|
||||
records = [
|
||||
_record(
|
||||
document_id="doc-1", stage_name="extraction",
|
||||
start_time=0.0, end_time=2.0,
|
||||
input_tokens=200, output_tokens=100,
|
||||
cpu_seconds=1.0, gpu_seconds=0.5, gpu_memory_mb=4096.0,
|
||||
),
|
||||
_record(
|
||||
document_id="doc-1", stage_name="adjudication",
|
||||
start_time=2.0, end_time=5.0,
|
||||
input_tokens=500, output_tokens=200,
|
||||
cpu_seconds=0.2, gpu_seconds=2.5, gpu_memory_mb=8000.0,
|
||||
),
|
||||
_record(
|
||||
document_id="doc-2", stage_name="extraction",
|
||||
start_time=5.0, end_time=7.0,
|
||||
input_tokens=180, output_tokens=90,
|
||||
cpu_seconds=0.8, gpu_seconds=0.3, gpu_memory_mb=3500.0,
|
||||
),
|
||||
]
|
||||
report = evaluate_resources(records)
|
||||
|
||||
assert isinstance(report, ResourceEvaluationReport)
|
||||
assert report.document_count == 2
|
||||
|
||||
# Latency: doc-1 = 5s, doc-2 = 2s
|
||||
assert report.latency.count == 2
|
||||
assert report.latency.max == 5.0
|
||||
assert report.latency.min == 2.0
|
||||
|
||||
# Throughput: 2 docs / 7 seconds
|
||||
assert report.throughput.total_documents == 2
|
||||
assert report.throughput.total_wall_seconds == 7.0
|
||||
|
||||
# Token usage
|
||||
assert report.token_usage.total_input_tokens == 880
|
||||
assert report.token_usage.total_output_tokens == 390
|
||||
assert report.token_usage.total_tokens == 1270
|
||||
|
||||
# CPU
|
||||
assert report.cpu.total_cpu_seconds == 2.0
|
||||
|
||||
# GPU
|
||||
assert report.gpu.total_gpu_seconds == 3.3
|
||||
assert report.gpu.peak_gpu_memory_mb == 8000.0
|
||||
|
||||
# Memory (fallback to GPU memory)
|
||||
assert report.memory.peak_rss_memory_mb == 8000.0
|
||||
|
||||
# Efficiency
|
||||
assert report.efficiency.adjudication_gpu_seconds == 2.5
|
||||
assert report.efficiency.fast_path_cpu_seconds == 1.8
|
||||
|
||||
def test_with_rss_samples(self) -> None:
|
||||
records = [_record(gpu_memory_mb=5000.0)]
|
||||
report = evaluate_resources(records, rss_samples_mb=[512.0, 1024.0, 768.0])
|
||||
assert report.memory.peak_rss_memory_mb == 1024.0
|
||||
assert abs(report.memory.mean_working_set_mb - 768.0) < 1e-9
|
||||
|
||||
def test_per_stage_latency_sorted(self) -> None:
|
||||
records = [
|
||||
_record(stage_name="z_stage", start_time=0.0, end_time=1.0),
|
||||
_record(stage_name="a_stage", start_time=1.0, end_time=2.0),
|
||||
]
|
||||
report = evaluate_resources(records)
|
||||
stage_names = [s.stage_name for s in report.per_stage_latency]
|
||||
assert stage_names == ["a_stage", "z_stage"]
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Unit tests for sentiment macro-F1, micro-F1, direction accuracy, and calibration metrics.
|
||||
|
||||
Validates: Requirements 16.3, 16.4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from services.intelligence_pipeline_v3.evaluation.sentiment_metrics import (
|
||||
CalibrationResult,
|
||||
DirectionAccuracyResult,
|
||||
SentimentEvaluationReport,
|
||||
SentimentF1Result,
|
||||
SentimentLabel,
|
||||
SentimentPrediction,
|
||||
compute_calibration,
|
||||
compute_direction_accuracy,
|
||||
compute_sentiment_f1,
|
||||
evaluate_sentiment,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pred(
|
||||
company_entity_id: str,
|
||||
label: str,
|
||||
pos: float = 0.0,
|
||||
neg: float = 0.0,
|
||||
neu: float = 0.0,
|
||||
mix: float = 0.0,
|
||||
) -> SentimentPrediction:
|
||||
return SentimentPrediction(
|
||||
company_entity_id=company_entity_id,
|
||||
label=SentimentLabel(label),
|
||||
positive_prob=pos,
|
||||
negative_prob=neg,
|
||||
neutral_prob=neu,
|
||||
mixed_prob=mix,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sentiment F1 Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSentimentF1:
|
||||
def test_perfect_match(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive", pos=0.9, neg=0.05, neu=0.05),
|
||||
_pred("c2", "negative", pos=0.1, neg=0.8, neu=0.1),
|
||||
_pred("c3", "neutral", pos=0.1, neg=0.1, neu=0.8),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive", pos=0.85, neg=0.1, neu=0.05),
|
||||
_pred("c2", "negative", pos=0.05, neg=0.9, neu=0.05),
|
||||
_pred("c3", "neutral", pos=0.05, neg=0.05, neu=0.9),
|
||||
]
|
||||
result = compute_sentiment_f1(pred, gold)
|
||||
assert result.macro_f1 == 1.0
|
||||
assert result.micro_f1 == 1.0
|
||||
assert result.support == 3
|
||||
|
||||
def test_all_wrong(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"),
|
||||
_pred("c3", "neutral"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "negative"),
|
||||
_pred("c2", "neutral"),
|
||||
_pred("c3", "positive"),
|
||||
]
|
||||
result = compute_sentiment_f1(pred, gold)
|
||||
assert result.macro_f1 == 0.0
|
||||
assert result.micro_f1 == 0.0
|
||||
|
||||
def test_partial_match(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "positive"),
|
||||
_pred("c3", "negative"),
|
||||
_pred("c4", "neutral"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive"), # correct
|
||||
_pred("c2", "negative"), # wrong
|
||||
_pred("c3", "negative"), # correct
|
||||
_pred("c4", "neutral"), # correct
|
||||
]
|
||||
result = compute_sentiment_f1(pred, gold)
|
||||
# micro: overall accuracy across all label comparisons
|
||||
# TP: c1=pos correct, c3=neg correct, c4=neu correct = 3
|
||||
# Total predictions that match across all labels = 3
|
||||
assert result.micro_f1 == 0.75
|
||||
assert result.support == 4
|
||||
|
||||
def test_unmatched_predictions_ignored(self) -> None:
|
||||
gold = [_pred("c1", "positive")]
|
||||
pred = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c_unknown", "negative"), # no match in gold
|
||||
]
|
||||
result = compute_sentiment_f1(pred, gold)
|
||||
assert result.macro_f1 == 1.0
|
||||
assert result.support == 1
|
||||
|
||||
def test_empty_inputs(self) -> None:
|
||||
result = compute_sentiment_f1([], [])
|
||||
assert result.macro_f1 == 1.0
|
||||
assert result.micro_f1 == 1.0
|
||||
assert result.support == 0
|
||||
|
||||
def test_per_label_breakdown(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "positive"),
|
||||
_pred("c3", "negative"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive"), # TP for positive
|
||||
_pred("c2", "neutral"), # FN for positive, FP for neutral
|
||||
_pred("c3", "negative"), # TP for negative
|
||||
]
|
||||
result = compute_sentiment_f1(pred, gold)
|
||||
|
||||
# Positive: TP=1, FP=0, FN=1 -> P=1.0, R=0.5, F1=2/3
|
||||
assert result.per_label["positive"].precision == 1.0
|
||||
assert result.per_label["positive"].recall == 0.5
|
||||
assert abs(result.per_label["positive"].f1 - 2 / 3) < 1e-9
|
||||
|
||||
# Negative: TP=1, FP=0, FN=0 -> P=1.0, R=1.0, F1=1.0
|
||||
assert result.per_label["negative"].f1 == 1.0
|
||||
|
||||
# Neutral: TP=0, FP=1, FN=0 -> P=0.0, R=1.0, F1=0.0
|
||||
assert result.per_label["neutral"].precision == 0.0
|
||||
assert result.per_label["neutral"].recall == 1.0
|
||||
assert result.per_label["neutral"].f1 == 0.0
|
||||
|
||||
def test_mixed_label_support(self) -> None:
|
||||
gold = [_pred("c1", "mixed")]
|
||||
pred = [_pred("c1", "mixed", mix=0.7)]
|
||||
result = compute_sentiment_f1(pred, gold)
|
||||
assert result.per_label["mixed"].f1 == 1.0
|
||||
assert result.per_label["mixed"].support_gold == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direction Accuracy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDirectionAccuracy:
|
||||
def test_all_correct(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"),
|
||||
]
|
||||
result = compute_direction_accuracy(pred, gold)
|
||||
assert result.accuracy == 1.0
|
||||
assert result.correct == 2
|
||||
assert result.total == 2
|
||||
|
||||
def test_all_wrong(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "negative"),
|
||||
_pred("c2", "positive"),
|
||||
]
|
||||
result = compute_direction_accuracy(pred, gold)
|
||||
assert result.accuracy == 0.0
|
||||
assert result.correct == 0
|
||||
assert result.total == 2
|
||||
|
||||
def test_neutral_ignored(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "neutral"),
|
||||
_pred("c3", "negative"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "positive"), # gold is neutral, ignored
|
||||
_pred("c3", "negative"),
|
||||
]
|
||||
result = compute_direction_accuracy(pred, gold)
|
||||
assert result.accuracy == 1.0
|
||||
assert result.total == 2 # c2 excluded
|
||||
|
||||
def test_mixed_ignored(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "mixed"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"), # gold is mixed, ignored
|
||||
]
|
||||
result = compute_direction_accuracy(pred, gold)
|
||||
assert result.accuracy == 1.0
|
||||
assert result.total == 1
|
||||
|
||||
def test_pred_neutral_ignored(self) -> None:
|
||||
"""If predicted is neutral but gold is positive, pair is excluded."""
|
||||
gold = [_pred("c1", "positive")]
|
||||
pred = [_pred("c1", "neutral")]
|
||||
result = compute_direction_accuracy(pred, gold)
|
||||
assert result.total == 0
|
||||
assert result.accuracy == 1.0 # vacuously true
|
||||
|
||||
def test_empty_inputs(self) -> None:
|
||||
result = compute_direction_accuracy([], [])
|
||||
assert result.accuracy == 1.0
|
||||
assert result.total == 0
|
||||
|
||||
def test_unmatched_ignored(self) -> None:
|
||||
gold = [_pred("c1", "positive")]
|
||||
pred = [_pred("c_other", "negative")]
|
||||
result = compute_direction_accuracy(pred, gold)
|
||||
assert result.total == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calibration Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalibration:
|
||||
def test_perfect_calibration(self) -> None:
|
||||
"""When confidence exactly matches accuracy, ECE should be 0."""
|
||||
# All predictions are correct with confidence 1.0
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive", pos=1.0, neg=0.0, neu=0.0),
|
||||
_pred("c2", "negative", pos=0.0, neg=1.0, neu=0.0),
|
||||
]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
assert result.ece == 0.0
|
||||
assert result.n_samples == 2
|
||||
|
||||
def test_brier_score_perfect(self) -> None:
|
||||
"""Perfect predictions should have Brier score of 0."""
|
||||
gold = [_pred("c1", "positive")]
|
||||
pred = [_pred("c1", "positive", pos=1.0, neg=0.0, neu=0.0, mix=0.0)]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
assert result.brier_score == 0.0
|
||||
|
||||
def test_brier_score_worst_case(self) -> None:
|
||||
"""Completely wrong confidence should have high Brier score."""
|
||||
gold = [_pred("c1", "positive")]
|
||||
# Predicted negative with full confidence, gold is positive
|
||||
pred = [_pred("c1", "negative", pos=0.0, neg=1.0, neu=0.0, mix=0.0)]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
# Brier: (0-1)^2 + (1-0)^2 + (0-0)^2 + (0-0)^2 = 2.0
|
||||
assert abs(result.brier_score - 2.0) < 1e-9
|
||||
|
||||
def test_brier_score_uniform_probs(self) -> None:
|
||||
"""Uniform probabilities across 4 labels."""
|
||||
gold = [_pred("c1", "positive")]
|
||||
pred = [_pred("c1", "positive", pos=0.25, neg=0.25, neu=0.25, mix=0.25)]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
# Brier: (0.25-1)^2 + (0.25-0)^2 + (0.25-0)^2 + (0.25-0)^2
|
||||
# = 0.5625 + 0.0625 + 0.0625 + 0.0625 = 0.75
|
||||
assert abs(result.brier_score - 0.75) < 1e-9
|
||||
|
||||
def test_ece_with_overconfidence(self) -> None:
|
||||
"""High confidence but wrong predictions -> high ECE."""
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "positive"),
|
||||
]
|
||||
pred = [
|
||||
# Predicts negative with 0.9 confidence, wrong
|
||||
_pred("c1", "negative", pos=0.05, neg=0.9, neu=0.05, mix=0.0),
|
||||
# Predicts negative with 0.9 confidence, wrong
|
||||
_pred("c2", "negative", pos=0.05, neg=0.9, neu=0.05, mix=0.0),
|
||||
]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
# Both have confidence 0.9, both wrong -> fraction_positive=0.0
|
||||
# ECE = |0.9 - 0.0| = 0.9
|
||||
assert abs(result.ece - 0.9) < 1e-9
|
||||
|
||||
def test_reliability_bins_structure(self) -> None:
|
||||
"""Reliability bins should cover [0, 1] range."""
|
||||
gold = [_pred("c1", "positive")]
|
||||
pred = [_pred("c1", "positive", pos=0.7, neg=0.2, neu=0.1)]
|
||||
result = compute_calibration(pred, gold, n_bins=5)
|
||||
assert len(result.reliability_bins) == 5
|
||||
assert result.reliability_bins[0].bin_lower == 0.0
|
||||
assert result.reliability_bins[-1].bin_upper == 1.0
|
||||
|
||||
def test_empty_inputs(self) -> None:
|
||||
result = compute_calibration([], [], n_bins=10)
|
||||
assert result.ece == 0.0
|
||||
assert result.brier_score == 0.0
|
||||
assert result.n_samples == 0
|
||||
assert result.reliability_bins == []
|
||||
|
||||
def test_unmatched_predictions_ignored(self) -> None:
|
||||
gold = [_pred("c1", "positive")]
|
||||
pred = [_pred("c_other", "positive", pos=0.9)]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
assert result.n_samples == 0
|
||||
|
||||
def test_single_bin(self) -> None:
|
||||
"""Single bin should contain all samples."""
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive", pos=0.8, neg=0.1, neu=0.1),
|
||||
_pred("c2", "negative", pos=0.1, neg=0.7, neu=0.2),
|
||||
]
|
||||
result = compute_calibration(pred, gold, n_bins=1)
|
||||
assert len(result.reliability_bins) == 1
|
||||
assert result.reliability_bins[0].count == 2
|
||||
|
||||
def test_calibration_bins_count_sum(self) -> None:
|
||||
"""Total count across bins should equal n_samples."""
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"),
|
||||
_pred("c3", "neutral"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive", pos=0.7, neg=0.2, neu=0.1),
|
||||
_pred("c2", "negative", pos=0.1, neg=0.6, neu=0.3),
|
||||
_pred("c3", "neutral", pos=0.1, neg=0.1, neu=0.8),
|
||||
]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
total_count = sum(b.count for b in result.reliability_bins)
|
||||
assert total_count == result.n_samples
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full Evaluation Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvaluateSentiment:
|
||||
def test_full_evaluation(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive", pos=0.9, neg=0.05, neu=0.05),
|
||||
_pred("c2", "negative", pos=0.1, neg=0.8, neu=0.1),
|
||||
_pred("c3", "neutral", pos=0.1, neg=0.1, neu=0.8),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "positive", pos=0.85, neg=0.1, neu=0.05),
|
||||
_pred("c2", "negative", pos=0.05, neg=0.9, neu=0.05),
|
||||
_pred("c3", "neutral", pos=0.05, neg=0.05, neu=0.9),
|
||||
]
|
||||
report = evaluate_sentiment(pred, gold, n_bins=10, document_count=3)
|
||||
|
||||
assert isinstance(report, SentimentEvaluationReport)
|
||||
assert isinstance(report.f1_metrics, SentimentF1Result)
|
||||
assert isinstance(report.direction_accuracy, DirectionAccuracyResult)
|
||||
assert isinstance(report.calibration, CalibrationResult)
|
||||
assert report.document_count == 3
|
||||
assert report.f1_metrics.macro_f1 == 1.0
|
||||
assert report.direction_accuracy.accuracy == 1.0
|
||||
|
||||
def test_report_with_errors(self) -> None:
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c2", "negative"),
|
||||
]
|
||||
pred = [
|
||||
_pred("c1", "negative", pos=0.1, neg=0.8, neu=0.1),
|
||||
_pred("c2", "negative", pos=0.1, neg=0.8, neu=0.1),
|
||||
]
|
||||
report = evaluate_sentiment(pred, gold, document_count=2)
|
||||
|
||||
# c1 wrong direction, c2 correct
|
||||
assert report.direction_accuracy.accuracy == 0.5
|
||||
assert report.direction_accuracy.total == 2
|
||||
assert report.f1_metrics.support == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_duplicate_company_ids_uses_last_gold(self) -> None:
|
||||
"""When gold has duplicate IDs, dict lookup uses last occurrence."""
|
||||
gold = [
|
||||
_pred("c1", "positive"),
|
||||
_pred("c1", "negative"), # overwrites first
|
||||
]
|
||||
pred = [_pred("c1", "negative")]
|
||||
result = compute_sentiment_f1(pred, gold)
|
||||
# Gold dict will have c1 -> negative (last wins)
|
||||
assert result.per_label["negative"].f1 == 1.0
|
||||
|
||||
def test_all_same_label(self) -> None:
|
||||
"""All predictions and gold are the same label."""
|
||||
gold = [_pred(f"c{i}", "positive") for i in range(5)]
|
||||
pred = [_pred(f"c{i}", "positive", pos=0.9) for i in range(5)]
|
||||
result = compute_sentiment_f1(pred, gold)
|
||||
assert result.per_label["positive"].f1 == 1.0
|
||||
assert result.macro_f1 == 1.0
|
||||
|
||||
def test_calibration_boundary_confidence(self) -> None:
|
||||
"""Confidence of exactly 1.0 should be in the last bin."""
|
||||
gold = [_pred("c1", "positive")]
|
||||
pred = [_pred("c1", "positive", pos=1.0, neg=0.0, neu=0.0, mix=0.0)]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
# Last bin [0.9, 1.0] should have count 1
|
||||
assert result.reliability_bins[-1].count == 1
|
||||
|
||||
def test_calibration_zero_confidence(self) -> None:
|
||||
"""Confidence of 0.0 should be in the first bin."""
|
||||
gold = [_pred("c1", "positive")]
|
||||
# Label is positive but prob is 0.0 (inconsistent but valid input)
|
||||
pred = [_pred("c1", "positive", pos=0.0, neg=0.0, neu=0.0, mix=0.0)]
|
||||
result = compute_calibration(pred, gold, n_bins=10)
|
||||
# First bin [0.0, 0.1) should have count 1
|
||||
assert result.reliability_bins[0].count == 1
|
||||
Reference in New Issue
Block a user