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:
Celes Renata
2026-07-13 02:14:59 +00:00
parent 84634a365e
commit a72f336ad1
227 changed files with 50403 additions and 0 deletions
@@ -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