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 @@
"""Tests for the adjudication layer of Intelligence Pipeline v3."""
@@ -0,0 +1,559 @@
"""Tests for the adjudication layer of Intelligence Pipeline v3.
Covers:
- Schema models validate correctly
- Packet building includes only relevant chunks
- Evidence ID verification catches missing references
- VRAM gate enforcement
- Repeated failure routing to review
"""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.adjudication.deployment import (
APPROVED_MODEL,
APPROVED_VLLM_VERSION,
AlertConfig,
ConcurrencySemaphore,
check_vram_gate,
verify_structured_output,
)
from services.intelligence_pipeline_v3.adjudication.prompts import (
MAX_OUTPUT_TOKENS,
PromptMetadata,
build_adjudication_packet,
build_request_payload,
get_decision_json_schema,
)
from services.intelligence_pipeline_v3.adjudication.schemas import (
AdjudicationCandidate,
AdjudicationDecision,
AdjudicationQuestion,
CandidateType,
ConflictDescription,
ConflictType,
DecisionVerdict,
EvidencePacket,
QuestionCode,
)
from services.intelligence_pipeline_v3.adjudication.verification import (
AdjudicationRecord,
preserve_pre_and_post,
reject_unsupported_decisions,
route_repeated_failures,
verify_evidence_references,
)
from services.intelligence_pipeline_v3.segmenter.models import DocumentChunk
# --- Fixtures ---
def _make_chunk(chunk_id: str, doc_id: str = "doc-1", text: str = "Sample text") -> DocumentChunk:
return DocumentChunk(
chunk_id=chunk_id,
document_id=doc_id,
document_type="article",
start_char=0,
end_char=len(text),
text=text,
)
def _make_evidence(evidence_id: str, chunk_id: str = "chunk-1") -> EvidencePacket:
return EvidencePacket(
evidence_id=evidence_id,
chunk_id=chunk_id,
start_char=0,
end_char=10,
text="Evidence text",
source_document_id="doc-1",
)
def _make_candidate(
candidate_id: str,
source_chunk_ids: list[str] | None = None,
evidence_ids: list[str] | None = None,
) -> AdjudicationCandidate:
return AdjudicationCandidate(
candidate_id=candidate_id,
candidate_type=CandidateType.ENTITY,
label="Test Candidate",
source_chunk_ids=source_chunk_ids or [],
evidence_ids=evidence_ids or [],
)
def _make_decision(
decision_id: str = "dec-1",
evidence_ids: list[str] | None = None,
candidate_ids: list[str] | None = None,
) -> AdjudicationDecision:
return AdjudicationDecision(
decision_id=decision_id,
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
verdict=DecisionVerdict.ACCEPT,
candidate_ids=candidate_ids or ["cand-1"],
evidence_ids=evidence_ids or ["ev-1"],
reasoning="Test reasoning",
)
# --- Task 32: Schema model validation tests ---
class TestAdjudicationSchemas:
"""Test that schema models validate correctly."""
def test_adjudication_candidate_valid(self):
candidate = AdjudicationCandidate(
candidate_id="cand-1",
candidate_type=CandidateType.ENTITY,
label="Apple Inc.",
source_chunk_ids=["chunk-1", "chunk-2"],
evidence_ids=["ev-1"],
score=0.85,
)
assert candidate.candidate_id == "cand-1"
assert candidate.candidate_type == CandidateType.ENTITY
assert candidate.score == 0.85
def test_adjudication_candidate_score_bounds(self):
with pytest.raises(Exception):
AdjudicationCandidate(
candidate_id="cand-1",
candidate_type=CandidateType.ENTITY,
label="Test",
score=1.5, # Over 1.0
)
def test_conflict_description_requires_two_candidates(self):
with pytest.raises(Exception):
ConflictDescription(
conflict_id="conf-1",
conflict_type=ConflictType.CONTRADICTORY_VALUES,
candidate_ids=["only-one"], # Needs at least 2
description="Test conflict",
)
def test_conflict_description_valid(self):
conflict = ConflictDescription(
conflict_id="conf-1",
conflict_type=ConflictType.AMBIGUOUS_IDENTITY,
candidate_ids=["cand-1", "cand-2"],
description="Two candidates for same entity",
evidence_ids=["ev-1"],
)
assert len(conflict.candidate_ids) == 2
def test_adjudication_question_valid(self):
question = AdjudicationQuestion(
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
description="Which company does AAPL refer to here?",
candidate_ids=["cand-1", "cand-2"],
)
assert question.question_code == QuestionCode.RESOLVE_ENTITY_IDENTITY
def test_evidence_packet_valid(self):
evidence = EvidencePacket(
evidence_id="ev-1",
chunk_id="chunk-1",
start_char=10,
end_char=50,
text="Apple reported revenue of $94.8B",
source_document_id="doc-1",
)
assert evidence.start_char == 10
assert evidence.end_char == 50
def test_decision_excludes_confidence_novelty_impact_horizon(self):
"""Task 32.2: Decision model excludes confidence, novelty, impact, horizon."""
fields = set(AdjudicationDecision.model_fields.keys())
# These fields MUST NOT be in the decision model
assert "confidence" not in fields
assert "novelty" not in fields
assert "impact" not in fields
assert "impact_score" not in fields
assert "horizon" not in fields
assert "impact_horizon" not in fields
def test_decision_requires_evidence_ids(self):
"""Task 32.3: Every decision requires evidence_ids."""
with pytest.raises(Exception):
AdjudicationDecision(
decision_id="dec-1",
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
verdict=DecisionVerdict.ACCEPT,
candidate_ids=["cand-1"],
evidence_ids=[], # Empty — min_length=1 should reject
reasoning="No evidence",
)
def test_decision_valid_with_evidence(self):
"""Task 32.3: Decision with evidence_ids is accepted."""
decision = AdjudicationDecision(
decision_id="dec-1",
question_code=QuestionCode.RESOLVE_CAUSAL_DIRECTION,
verdict=DecisionVerdict.MERGE,
candidate_ids=["cand-1", "cand-2"],
evidence_ids=["ev-1", "ev-2"],
reasoning="Both refer to same event",
resolved_value={"merged_event": "earnings_beat"},
)
assert len(decision.evidence_ids) == 2
assert decision.verdict == DecisionVerdict.MERGE
# --- Task 33: Focused adjudication prompts tests ---
class TestAdjudicationPrompts:
"""Test packet building and prompt configuration."""
def test_packet_includes_only_relevant_chunks(self):
"""Task 33.1: Packet includes only relevant chunks."""
chunks = [
_make_chunk("chunk-1", text="Relevant chunk about Apple"),
_make_chunk("chunk-2", text="Irrelevant chunk about weather"),
_make_chunk("chunk-3", text="Another relevant chunk"),
]
candidates = [
_make_candidate("cand-1", source_chunk_ids=["chunk-1", "chunk-3"]),
]
questions = [
AdjudicationQuestion(
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
description="Resolve Apple identity",
candidate_ids=["cand-1"],
),
]
evidence = [_make_evidence("ev-1", "chunk-1")]
packet = build_adjudication_packet(
document_id="doc-1",
document_type="article",
document_chunks=chunks,
candidates=candidates,
conflicts=[],
questions=questions,
evidence=evidence,
)
# Only chunk-1 and chunk-3 should be included
chunk_ids = [c.chunk_id for c in packet.relevant_chunks]
assert "chunk-1" in chunk_ids
assert "chunk-3" in chunk_ids
assert "chunk-2" not in chunk_ids
def test_packet_uses_strict_json_schema(self):
"""Task 33.2: Uses strict JSON Schema and temperature zero."""
schema = get_decision_json_schema()
assert schema["type"] == "object"
assert "decisions" in schema["properties"]
assert schema["additionalProperties"] is False
# Verify required evidence_ids in decisions
decision_schema = schema["properties"]["decisions"]["items"]
assert "evidence_ids" in decision_schema["required"]
def test_request_payload_temperature_zero(self):
"""Task 33.2: Temperature is zero for deterministic output."""
chunks = [_make_chunk("chunk-1")]
candidates = [_make_candidate("cand-1", source_chunk_ids=["chunk-1"])]
questions = [
AdjudicationQuestion(
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
description="Resolve identity",
),
]
evidence = [_make_evidence("ev-1")]
packet = build_adjudication_packet(
document_id="doc-1",
document_type="article",
document_chunks=chunks,
candidates=candidates,
conflicts=[],
questions=questions,
evidence=evidence,
)
payload = build_request_payload(packet)
assert payload["temperature"] == 0.0
assert payload["response_format"]["type"] == "json_schema"
assert payload["response_format"]["json_schema"]["strict"] is True
def test_bounded_output_budget(self):
"""Task 33.3: Bounded output budget max 1536 tokens."""
assert MAX_OUTPUT_TOKENS == 1536
chunks = [_make_chunk("chunk-1")]
candidates = [_make_candidate("cand-1", source_chunk_ids=["chunk-1"])]
questions = [
AdjudicationQuestion(
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
description="Test",
),
]
evidence = [_make_evidence("ev-1")]
packet = build_adjudication_packet(
document_id="doc-1",
document_type="article",
document_chunks=chunks,
candidates=candidates,
conflicts=[],
questions=questions,
evidence=evidence,
)
payload = build_request_payload(packet)
assert payload["max_tokens"] == 1536
def test_prompt_metadata_fields(self):
"""Task 33.4: PromptMetadata has version, schema_version, provider lineage."""
meta = PromptMetadata()
assert meta.prompt_version == "1.0.0"
assert meta.schema_version == "1.0.0"
assert meta.provider_lineage == "adjudication_v3"
assert meta.max_output_tokens == 1536
assert meta.temperature == 0.0
# --- Task 34: 9B deployment config tests ---
class TestDeploymentConfig:
"""Test deployment constants and VRAM gating."""
def test_approved_model_constant(self):
"""Task 34.1: Pinned approved model."""
assert APPROVED_MODEL == "AxionML/Qwen3.5-9B-NVFP4"
def test_approved_vllm_version(self):
"""Task 34.1: Pinned vLLM version."""
assert APPROVED_VLLM_VERSION == "0.8.5"
def test_verify_structured_output_passes(self):
"""Task 34.2: Verify structured output with valid target."""
target = {
"capabilities": {"json_schema": True},
"served_model_name": "stonks-adjudicator-9b",
"vllm_version": "0.8.5",
"model": "AxionML/Qwen3.5-9B-NVFP4",
}
assert verify_structured_output(target) is True
def test_verify_structured_output_fails_no_schema(self):
"""Task 34.2: Fails without json_schema capability."""
target = {
"capabilities": {"json_schema": False},
"served_model_name": "stonks-adjudicator-9b",
"vllm_version": "0.8.5",
}
assert verify_structured_output(target) is False
def test_verify_structured_output_fails_wrong_model(self):
"""Task 34.2: Fails with wrong model name."""
target = {
"capabilities": {"json_schema": True},
"served_model_name": "stonks-adjudicator-9b",
"vllm_version": "0.8.5",
"model": "wrong-model/7B",
}
assert verify_structured_output(target) is False
def test_vram_gate_within_limit(self):
"""Task 34.3: VRAM within +5% passes."""
baseline = 10000.0 # 10 GB
peak = 10400.0 # 4% over -> passes
assert check_vram_gate(peak, baseline) is True
def test_vram_gate_at_limit(self):
"""Task 34.3: VRAM at exactly +5% passes."""
baseline = 10000.0
peak = 10500.0 # Exactly 5%
assert check_vram_gate(peak, baseline) is True
def test_vram_gate_over_limit(self):
"""Task 34.3: VRAM over +5% fails."""
baseline = 10000.0
peak = 10501.0 # Just over 5%
assert check_vram_gate(peak, baseline) is False
def test_vram_gate_zero_baseline(self):
"""Task 34.3: Zero baseline returns False."""
assert check_vram_gate(100.0, 0.0) is False
def test_concurrency_semaphore_defaults(self):
"""Task 34.4: Semaphore defaults match vLLM max-num-seqs."""
sem_config = ConcurrencySemaphore()
assert sem_config.max_concurrent == 8
assert sem_config.queue_timeout_seconds == 120.0
def test_concurrency_semaphore_creates_asyncio_semaphore(self):
"""Task 34.4: Can create an asyncio semaphore."""
sem_config = ConcurrencySemaphore(max_concurrent=4)
sem = sem_config.create_semaphore()
# asyncio.Semaphore has _value attribute
assert sem._value == 4
def test_alert_config_defaults(self):
"""Task 34.5: Alert config has queue-depth and availability thresholds."""
config = AlertConfig()
assert config.queue_depth_warning == 16
assert config.queue_depth_critical == 32
assert config.availability_threshold_percent == 95.0
assert config.consecutive_failures_alert == 3
def test_alert_config_custom(self):
"""Task 34.5: Alert config accepts custom values."""
config = AlertConfig(
queue_depth_warning=8,
queue_depth_critical=16,
availability_threshold_percent=99.0,
latency_p95_warning_ms=3000,
)
assert config.queue_depth_warning == 8
assert config.latency_p95_warning_ms == 3000
# --- Task 35: Post-adjudication verification tests ---
class TestPostAdjudicationVerification:
"""Test evidence verification and failure routing."""
def test_verify_evidence_references_all_present(self):
"""Task 35.1: No missing refs when all evidence IDs are in packet."""
chunks = [_make_chunk("chunk-1")]
evidence = [_make_evidence("ev-1"), _make_evidence("ev-2")]
candidates = [_make_candidate("cand-1", source_chunk_ids=["chunk-1"])]
questions = [
AdjudicationQuestion(
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
description="Test",
),
]
packet = build_adjudication_packet(
document_id="doc-1",
document_type="article",
document_chunks=chunks,
candidates=candidates,
conflicts=[],
questions=questions,
evidence=evidence,
)
decision = _make_decision(evidence_ids=["ev-1", "ev-2"])
missing = verify_evidence_references(decision, packet)
assert missing == []
def test_verify_evidence_references_catches_missing(self):
"""Task 35.1: Catches evidence IDs not in the packet."""
chunks = [_make_chunk("chunk-1")]
evidence = [_make_evidence("ev-1")]
candidates = [_make_candidate("cand-1", source_chunk_ids=["chunk-1"])]
questions = [
AdjudicationQuestion(
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
description="Test",
),
]
packet = build_adjudication_packet(
document_id="doc-1",
document_type="article",
document_chunks=chunks,
candidates=candidates,
conflicts=[],
questions=questions,
evidence=evidence,
)
# Decision references ev-3 which is NOT in the packet
decision = _make_decision(evidence_ids=["ev-1", "ev-3"])
missing = verify_evidence_references(decision, packet)
assert "ev-3" in missing
assert "ev-1" not in missing
def test_reject_unsupported_empty_evidence(self):
"""Task 35.2: Rejects decisions with empty evidence_ids."""
# Create decision manually bypassing validator
decision = AdjudicationDecision.model_construct(
decision_id="dec-1",
question_code=QuestionCode.RESOLVE_ENTITY_IDENTITY,
verdict=DecisionVerdict.ACCEPT,
candidate_ids=["cand-1"],
evidence_ids=[],
reasoning="No evidence",
resolved_value={},
)
result = reject_unsupported_decisions(decision)
assert result.rejected is True
assert any("empty_evidence" in r.value for r in result.reasons)
def test_reject_unsupported_invalid_candidate_ref(self):
"""Task 35.2: Rejects decisions referencing invalid candidates."""
decision = _make_decision(candidate_ids=["cand-99"])
result = reject_unsupported_decisions(
decision,
valid_candidate_ids={"cand-1", "cand-2"},
)
assert result.rejected is True
def test_accept_valid_decision(self):
"""Task 35.2: Accepts schema-compatible decisions."""
decision = _make_decision(
evidence_ids=["ev-1"],
candidate_ids=["cand-1"],
)
result = reject_unsupported_decisions(
decision,
valid_candidate_ids={"cand-1"},
valid_evidence_ids={"ev-1"},
)
assert result.rejected is False
assert result.reasons == []
def test_preserve_pre_and_post(self):
"""Task 35.3: Stores both pre-candidates and post-decisions."""
candidates = [
_make_candidate("cand-1"),
_make_candidate("cand-2"),
]
decisions = [_make_decision("dec-1")]
record = preserve_pre_and_post(
document_id="doc-1",
pre_candidates=candidates,
post_decisions=decisions,
packet_evidence_ids=["ev-1", "ev-2"],
)
assert isinstance(record, AdjudicationRecord)
assert record.document_id == "doc-1"
assert len(record.pre_candidates) == 2
assert len(record.post_decisions) == 1
assert record.packet_evidence_ids == ["ev-1", "ev-2"]
assert record.timestamp is not None
def test_route_repeated_failures_to_review(self):
"""Task 35.4: Routes repeated failures to 'review'."""
assert route_repeated_failures(3, 3) == "review"
assert route_repeated_failures(5, 3) == "review"
assert route_repeated_failures(10, 5) == "review"
def test_route_never_returns_accept_repaired(self):
"""Task 35.4: Never returns 'accept_repaired'."""
# Even below threshold, should route to review
result = route_repeated_failures(1, 3)
assert result == "review"
assert result != "accept_repaired"
result = route_repeated_failures(0, 3)
assert result == "review"
assert result != "accept_repaired"
@@ -0,0 +1,249 @@
"""Tests for benchmark comparison and attribution logic.
Validates: Requirements 16.2, 16.3, 16.5
"""
from __future__ import annotations
from services.intelligence_pipeline_v3.benchmark.comparison import (
ComparisonReport,
ConfigDelta,
FieldDelta,
ResourceDelta,
compare_configurations,
)
from services.intelligence_pipeline_v3.benchmark.runner import (
BenchmarkDocumentResult,
BenchmarkRun,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_result(
doc_id: str,
*,
schema_valid: bool = True,
duration_ms: int = 100,
input_tokens: int = 500,
output_tokens: int = 200,
retries: int = 0,
error: str | None = None,
) -> BenchmarkDocumentResult:
"""Helper to create a BenchmarkDocumentResult."""
return BenchmarkDocumentResult(
document_id=doc_id,
raw_output='{"test": true}' if schema_valid else "invalid",
parsed_output={"test": True} if schema_valid else None,
schema_valid=schema_valid,
retries=retries,
duration_ms=duration_ms,
input_tokens=input_tokens,
output_tokens=output_tokens,
error=error,
)
def _make_run(
config_name: str,
results: list[BenchmarkDocumentResult],
) -> BenchmarkRun:
"""Helper to create a BenchmarkRun."""
return BenchmarkRun(
config_name=config_name,
document_ids=[r.document_id for r in results],
results=results,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestCompareConfigurations:
"""Tests for compare_configurations function."""
def test_empty_comparison_runs(self) -> None:
baseline = _make_run("baseline_current", [
_make_result("doc1", schema_valid=True),
])
report = compare_configurations(baseline, [])
assert report.configs_compared == ["baseline_current"]
assert report.deltas == []
def test_basic_comparison_produces_deltas(self) -> None:
# Baseline: 50% schema validity
baseline = _make_run("baseline_current", [
_make_result("doc1", schema_valid=True),
_make_result("doc2", schema_valid=False, error="parse error"),
])
# Temp zero: 100% schema validity
temp_zero = _make_run("baseline_temp_zero", [
_make_result("doc1", schema_valid=True),
_make_result("doc2", schema_valid=True),
])
report = compare_configurations(baseline, [temp_zero])
assert len(report.configs_compared) == 2
assert len(report.deltas) == 1
delta = report.deltas[0]
assert delta.baseline_config == "baseline_current"
assert delta.comparison_config == "baseline_temp_zero"
assert len(delta.field_deltas) > 0
assert len(delta.resource_deltas) > 0
def test_schema_validity_improvement_detected(self) -> None:
baseline = _make_run("baseline_current", [
_make_result("doc1", schema_valid=True),
_make_result("doc2", schema_valid=False, error="err"),
_make_result("doc3", schema_valid=False, error="err"),
_make_result("doc4", schema_valid=True),
])
strict = _make_run("baseline_strict_schema", [
_make_result("doc1", schema_valid=True),
_make_result("doc2", schema_valid=True),
_make_result("doc3", schema_valid=True),
_make_result("doc4", schema_valid=True),
])
report = compare_configurations(baseline, [strict])
delta = report.deltas[0]
# Find the schema_validity_rate field delta
validity_delta = next(
(d for d in delta.field_deltas if d.field_name == "schema_validity_rate"),
None,
)
assert validity_delta is not None
assert validity_delta.improved is True
assert validity_delta.comparison_value == 1.0
assert validity_delta.baseline_value == 0.5
def test_attribution_with_incremental_improvement(self) -> None:
# Baseline: 50% valid
baseline = _make_run("baseline_current", [
_make_result("d1", schema_valid=True),
_make_result("d2", schema_valid=False, error="e"),
])
# Temp zero: 75% (fixes half the remaining)
# We simulate by 3/4 valid
temp_zero = _make_run("baseline_temp_zero", [
_make_result("d1", schema_valid=True),
_make_result("d2", schema_valid=True),
_make_result("d3", schema_valid=True),
_make_result("d4", schema_valid=False, error="e"),
])
# Strict schema: 100% valid
strict = _make_run("baseline_strict_schema", [
_make_result("d1", schema_valid=True),
_make_result("d2", schema_valid=True),
])
report = compare_configurations(baseline, [temp_zero, strict])
# Attribution should exist
assert "temperature_fix" in report.attribution_summary
assert "schema_constraint" in report.attribution_summary
# All attribution values should be between 0 and 1
for val in report.attribution_summary.values():
assert 0.0 <= val <= 1.0
def test_attribution_no_improvement(self) -> None:
# Both configurations have same validity
baseline = _make_run("baseline_current", [
_make_result("d1", schema_valid=True),
])
temp_zero = _make_run("baseline_temp_zero", [
_make_result("d1", schema_valid=True),
])
report = compare_configurations(baseline, [temp_zero])
# No improvement means zero attribution
assert report.attribution_summary.get("temperature_fix", 0.0) == 0.0
assert report.attribution_summary.get("schema_constraint", 0.0) == 0.0
def test_resource_improvement_lower_is_better(self) -> None:
baseline = _make_run("baseline_current", [
_make_result("d1", duration_ms=500, retries=3),
])
improved = _make_run("baseline_temp_zero", [
_make_result("d1", duration_ms=200, retries=0),
])
report = compare_configurations(baseline, [improved])
delta = report.deltas[0]
# Duration should show improvement (lower)
duration_delta = next(
(d for d in delta.resource_deltas if d.metric_name == "mean_duration_ms"),
None,
)
assert duration_delta is not None
assert duration_delta.improved is True
assert duration_delta.comparison_value < duration_delta.baseline_value
def test_multiple_comparisons(self) -> None:
baseline = _make_run("baseline_current", [
_make_result("d1", schema_valid=True),
])
comp1 = _make_run("baseline_temp_zero", [
_make_result("d1", schema_valid=True),
])
comp2 = _make_run("baseline_strict_schema", [
_make_result("d1", schema_valid=True),
])
report = compare_configurations(baseline, [comp1, comp2])
assert len(report.deltas) == 2
assert report.configs_compared == [
"baseline_current",
"baseline_temp_zero",
"baseline_strict_schema",
]
class TestComparisonReportModel:
"""Tests for the ComparisonReport Pydantic model."""
def test_serialization_roundtrip(self) -> None:
report = ComparisonReport(
configs_compared=["a", "b"],
deltas=[
ConfigDelta(
baseline_config="a",
comparison_config="b",
field_deltas=[
FieldDelta(
field_name="accuracy",
baseline_value=0.5,
comparison_value=0.8,
absolute_delta=0.3,
relative_delta_percent=60.0,
improved=True,
)
],
resource_deltas=[
ResourceDelta(
metric_name="latency_ms",
baseline_value=500.0,
comparison_value=300.0,
absolute_delta=-200.0,
relative_delta_percent=-40.0,
improved=True,
)
],
)
],
attribution_summary={"temperature_fix": 0.6, "schema_constraint": 0.4},
)
json_str = report.model_dump_json()
restored = ComparisonReport.model_validate_json(json_str)
assert restored.configs_compared == report.configs_compared
assert len(restored.deltas) == 1
assert restored.attribution_summary["temperature_fix"] == 0.6
@@ -0,0 +1,145 @@
"""Tests for benchmark configuration definitions.
Validates: Requirements 16.2, 16.3, 16.5
"""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.benchmark.configurations import (
BASELINE_CURRENT,
BASELINE_STRICT_SCHEMA,
BASELINE_TEMP_ZERO,
BenchmarkConfig,
StructuredOutputMode,
list_configurations,
)
class TestBenchmarkConfig:
"""Tests for BenchmarkConfig model validation."""
def test_valid_config_creation(self) -> None:
config = BenchmarkConfig(
config_name="test",
description="A test config",
model_name="test-model",
temperature=0.5,
max_output_tokens=1024,
structured_output_mode=StructuredOutputMode.NONE,
)
assert config.config_name == "test"
assert config.temperature == 0.5
assert config.seed is None
assert config.additional_params == {}
def test_temperature_bounds(self) -> None:
with pytest.raises(Exception):
BenchmarkConfig(
config_name="bad",
description="bad temp",
model_name="m",
temperature=-0.1,
max_output_tokens=100,
structured_output_mode=StructuredOutputMode.NONE,
)
with pytest.raises(Exception):
BenchmarkConfig(
config_name="bad",
description="bad temp",
model_name="m",
temperature=2.1,
max_output_tokens=100,
structured_output_mode=StructuredOutputMode.NONE,
)
def test_max_output_tokens_must_be_positive(self) -> None:
with pytest.raises(Exception):
BenchmarkConfig(
config_name="bad",
description="bad tokens",
model_name="m",
temperature=0.0,
max_output_tokens=0,
structured_output_mode=StructuredOutputMode.NONE,
)
def test_config_is_frozen(self) -> None:
config = BenchmarkConfig(
config_name="frozen",
description="immutable",
model_name="m",
temperature=0.0,
max_output_tokens=512,
structured_output_mode=StructuredOutputMode.NONE,
)
with pytest.raises(Exception):
config.temperature = 1.0 # type: ignore[misc]
class TestStandardConfigurations:
"""Tests for the predefined standard configurations."""
def test_baseline_current_uses_temperature_07(self) -> None:
assert BASELINE_CURRENT.temperature == 0.7
def test_baseline_current_uses_json_object(self) -> None:
assert BASELINE_CURRENT.structured_output_mode == StructuredOutputMode.JSON_OBJECT
def test_baseline_current_no_seed(self) -> None:
assert BASELINE_CURRENT.seed is None
def test_baseline_temp_zero_is_deterministic(self) -> None:
assert BASELINE_TEMP_ZERO.temperature == 0.0
assert BASELINE_TEMP_ZERO.seed == 0
def test_baseline_temp_zero_same_model(self) -> None:
assert BASELINE_TEMP_ZERO.model_name == BASELINE_CURRENT.model_name
def test_baseline_temp_zero_still_json_object(self) -> None:
assert BASELINE_TEMP_ZERO.structured_output_mode == StructuredOutputMode.JSON_OBJECT
def test_baseline_strict_schema_uses_json_schema(self) -> None:
assert BASELINE_STRICT_SCHEMA.structured_output_mode == StructuredOutputMode.JSON_SCHEMA
def test_baseline_strict_schema_temp_zero(self) -> None:
assert BASELINE_STRICT_SCHEMA.temperature == 0.0
def test_baseline_strict_schema_same_model(self) -> None:
assert BASELINE_STRICT_SCHEMA.model_name == BASELINE_CURRENT.model_name
def test_all_configs_have_unique_names(self) -> None:
configs = list_configurations()
names = [c.config_name for c in configs]
assert len(names) == len(set(names))
def test_list_configurations_returns_all_three(self) -> None:
configs = list_configurations()
assert len(configs) == 3
names = {c.config_name for c in configs}
assert "baseline_current" in names
assert "baseline_temp_zero" in names
assert "baseline_strict_schema" in names
def test_all_configs_use_same_max_output_tokens(self) -> None:
configs = list_configurations()
tokens = {c.max_output_tokens for c in configs}
assert len(tokens) == 1 # All should agree
def test_all_configs_use_same_model(self) -> None:
configs = list_configurations()
models = {c.model_name for c in configs}
assert len(models) == 1
class TestStructuredOutputMode:
"""Tests for the StructuredOutputMode enum."""
def test_values(self) -> None:
assert StructuredOutputMode.NONE.value == "none"
assert StructuredOutputMode.JSON_OBJECT.value == "json_object"
assert StructuredOutputMode.JSON_SCHEMA.value == "json_schema"
def test_enum_members(self) -> None:
assert len(StructuredOutputMode) == 3
@@ -0,0 +1,509 @@
"""Golden mapping tests for the v3→v2 compatibility adapter.
Tests cover:
- Every legacy sentiment enum value is reachable
- impact_score stays in [-1, 1]
- impact_horizon is one of the valid strings
- novelty_score stays in [0, 1]
- confidence stays in [0, 1]
- Adapter disabled by default (mode=disabled raises)
- Adapter enabled in replay mode
- model_provider = 'hybrid' is always set
- Lineage includes adapter version
"""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.compatibility.adapter import (
ADAPTER_VERSION,
AdapterDisabledError,
CompatibilityAdapter,
)
from services.intelligence_pipeline_v3.compatibility.config import (
DEFAULT_ADAPTER_MODE,
AdapterMode,
is_adapter_enabled,
)
from services.intelligence_pipeline_v3.compatibility.models import (
V3CompanySignal,
V3DirectionProbabilities,
V3HorizonProbabilities,
V3IntelligenceRecord,
V3SentimentDistribution,
V3StageRun,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SENTINEL = object()
def _make_signal(
*,
sentiment: V3SentimentDistribution | None = None,
horizon: V3HorizonProbabilities | None = None,
direction: V3DirectionProbabilities | None = None,
expected_magnitude: float | None = None,
event_classes: list[str] | None | object = _SENTINEL,
) -> V3CompanySignal:
"""Factory for a minimal v3 company signal with overrides."""
if event_classes is _SENTINEL:
event_classes = ["earnings_beat"]
return V3CompanySignal(
company_id="aaaaaaaa-1111-2222-3333-444444444444",
ticker="AAPL",
relevance_probability=0.9,
event_classes=event_classes or [],
sentiment=sentiment or V3SentimentDistribution(positive=0.7, negative=0.1, neutral=0.2),
direction_probabilities=direction or V3DirectionProbabilities(positive=0.6, negative=0.2, neutral=0.2),
horizon_probabilities=horizon or V3HorizonProbabilities(one_day=0.6, seven_day=0.3, thirty_day=0.1),
expected_magnitude=expected_magnitude,
evidence_spans=["span-1", "span-2"],
)
def _make_v3_record(signals: list[V3CompanySignal] | None = None) -> V3IntelligenceRecord:
"""Factory for a minimal v3 intelligence record."""
return V3IntelligenceRecord(
document_id="doc-001",
document_type="article",
summary="Test summary",
macro_themes=["earnings", "technology"],
novelty_score=0.7,
confidence=0.85,
company_signals=signals or [_make_signal()],
stage_runs=[
V3StageRun(stage="segmenter", schema_version="1.0.0", duration_ms=50),
V3StageRun(stage="specialist", model_version="gliner2-large-v1", schema_version="1.0.0", duration_ms=200),
V3StageRun(stage="sentiment", model_version="finbert-v1", schema_version="1.0.0", duration_ms=100),
],
pipeline_version="3.0.0",
)
# ---------------------------------------------------------------------------
# Task 22.4: Adapter disabled outside replay/shadow mode
# ---------------------------------------------------------------------------
class TestAdapterDisabled:
"""Verify adapter is gated by mode — disabled by default."""
def test_default_mode_is_disabled(self) -> None:
assert DEFAULT_ADAPTER_MODE == AdapterMode.DISABLED
def test_is_adapter_enabled_false_for_disabled(self) -> None:
assert is_adapter_enabled(AdapterMode.DISABLED) is False
def test_is_adapter_enabled_true_for_replay(self) -> None:
assert is_adapter_enabled(AdapterMode.REPLAY_ONLY) is True
def test_is_adapter_enabled_true_for_shadow(self) -> None:
assert is_adapter_enabled(AdapterMode.SHADOW_ONLY) is True
def test_is_adapter_enabled_true_for_canary(self) -> None:
assert is_adapter_enabled(AdapterMode.CANARY) is True
def test_is_adapter_enabled_true_for_production(self) -> None:
assert is_adapter_enabled(AdapterMode.PRODUCTION) is True
def test_disabled_adapter_raises_on_map(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.DISABLED)
with pytest.raises(AdapterDisabledError):
adapter.map_to_v2(_make_v3_record())
def test_replay_adapter_succeeds(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2_record, lineage = adapter.map_to_v2(_make_v3_record())
assert v2_record is not None
assert lineage is not None
def test_shadow_adapter_succeeds(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.SHADOW_ONLY)
v2_record, _lineage = adapter.map_to_v2(_make_v3_record())
assert v2_record is not None
# ---------------------------------------------------------------------------
# Task 22.1 / 22.2: Mapping and lineage
# ---------------------------------------------------------------------------
class TestModelProviderHybrid:
"""Verify model_provider is always 'hybrid'."""
def test_model_provider_is_hybrid(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2_record, _lineage = adapter.map_to_v2(_make_v3_record())
assert v2_record.model_provider == "hybrid"
def test_model_name_is_pipeline_v3(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2_record, _lineage = adapter.map_to_v2(_make_v3_record())
assert v2_record.model_name == "intelligence-pipeline-v3"
class TestLineage:
"""Verify lineage records adapter version and stage details."""
def test_lineage_includes_adapter_version(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
_v2_record, lineage = adapter.map_to_v2(_make_v3_record())
assert lineage.adapter_version == ADAPTER_VERSION
def test_lineage_includes_pipeline_version(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
_v2_record, lineage = adapter.map_to_v2(_make_v3_record())
assert lineage.pipeline_version == "3.0.0"
def test_lineage_includes_stage_runs(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
_v2_record, lineage = adapter.map_to_v2(_make_v3_record())
assert len(lineage.stage_runs) == 3
stages = [sr.stage for sr in lineage.stage_runs]
assert "segmenter" in stages
assert "specialist" in stages
assert "sentiment" in stages
def test_lineage_links_v3_to_v2(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2_record, lineage = adapter.map_to_v2(_make_v3_record())
assert lineage.v3_document_id == "doc-001"
assert lineage.v2_intelligence_id == v2_record.id
# ---------------------------------------------------------------------------
# Task 22.3: Golden mapping tests — sentiment enum
# ---------------------------------------------------------------------------
class TestSentimentMapping:
"""Every legacy sentiment enum value (positive/negative/neutral/mixed) is reachable."""
def test_positive_sentiment(self) -> None:
signal = _make_signal(
sentiment=V3SentimentDistribution(positive=0.8, negative=0.1, neutral=0.1)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].sentiment == "positive"
def test_negative_sentiment(self) -> None:
signal = _make_signal(
sentiment=V3SentimentDistribution(positive=0.1, negative=0.8, neutral=0.1)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].sentiment == "negative"
def test_neutral_sentiment(self) -> None:
signal = _make_signal(
sentiment=V3SentimentDistribution(positive=0.1, negative=0.1, neutral=0.8)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].sentiment == "neutral"
def test_mixed_sentiment(self) -> None:
signal = _make_signal(
sentiment=V3SentimentDistribution(positive=0.4, negative=0.4, neutral=0.2)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].sentiment == "mixed"
def test_mixed_threshold_boundary(self) -> None:
"""Both positive and negative at exactly 0.3 triggers mixed."""
signal = _make_signal(
sentiment=V3SentimentDistribution(positive=0.3, negative=0.3, neutral=0.4)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].sentiment == "mixed"
# ---------------------------------------------------------------------------
# Task 22.3: Golden mapping tests — impact_score range
# ---------------------------------------------------------------------------
class TestImpactScoreRange:
"""impact_score stays in [-1, 1]."""
def test_impact_score_from_magnitude(self) -> None:
signal = _make_signal(expected_magnitude=0.5)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert -1.0 <= v2.impact_records[0].impact_score <= 1.0
assert v2.impact_records[0].impact_score == 0.5
def test_impact_score_clamped_high(self) -> None:
signal = _make_signal(expected_magnitude=2.5)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_score == 1.0
def test_impact_score_clamped_low(self) -> None:
signal = _make_signal(expected_magnitude=-3.0)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_score == -1.0
def test_impact_score_negative_magnitude(self) -> None:
signal = _make_signal(expected_magnitude=-0.7)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_score == -0.7
def test_impact_score_fallback_from_direction(self) -> None:
"""When expected_magnitude is None, derive from direction probabilities."""
signal = _make_signal(
expected_magnitude=None,
direction=V3DirectionProbabilities(positive=0.8, negative=0.1, neutral=0.1),
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
score = v2.impact_records[0].impact_score
assert -1.0 <= score <= 1.0
# 0.8 - 0.1 = 0.7
assert abs(score - 0.7) < 1e-9
def test_impact_score_zero(self) -> None:
signal = _make_signal(expected_magnitude=0.0)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_score == 0.0
# ---------------------------------------------------------------------------
# Task 22.3: Golden mapping tests — impact_horizon valid strings
# ---------------------------------------------------------------------------
VALID_HORIZONS = {"intraday", "1d", "7d", "30d", "90d"}
class TestImpactHorizonMapping:
"""impact_horizon is one of the valid legacy strings."""
def test_intraday_horizon(self) -> None:
signal = _make_signal(
horizon=V3HorizonProbabilities(intraday=0.9, one_day=0.05, seven_day=0.05)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_horizon == "intraday"
assert v2.impact_records[0].impact_horizon in VALID_HORIZONS
def test_one_day_horizon(self) -> None:
signal = _make_signal(
horizon=V3HorizonProbabilities(intraday=0.1, one_day=0.7, seven_day=0.2)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_horizon == "1d"
def test_seven_day_horizon(self) -> None:
signal = _make_signal(
horizon=V3HorizonProbabilities(seven_day=0.8, thirty_day=0.1, ninety_day=0.1)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_horizon == "7d"
def test_thirty_day_horizon(self) -> None:
signal = _make_signal(
horizon=V3HorizonProbabilities(thirty_day=0.9, ninety_day=0.1)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_horizon == "30d"
def test_ninety_day_horizon(self) -> None:
signal = _make_signal(
horizon=V3HorizonProbabilities(ninety_day=0.9)
)
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_horizon == "90d"
def test_horizon_always_valid(self) -> None:
"""Default horizon probs still produce a valid string."""
signal = _make_signal(horizon=V3HorizonProbabilities())
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].impact_horizon in VALID_HORIZONS
# ---------------------------------------------------------------------------
# Task 22.3: Golden mapping tests — novelty_score range
# ---------------------------------------------------------------------------
class TestNoveltyScoreRange:
"""novelty_score stays in [0, 1]."""
def test_novelty_passes_through(self) -> None:
record = _make_v3_record()
record.novelty_score = 0.7
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(record)
assert v2.novelty_score == 0.7
assert 0.0 <= v2.novelty_score <= 1.0
def test_novelty_zero(self) -> None:
record = _make_v3_record()
record.novelty_score = 0.0
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(record)
assert v2.novelty_score == 0.0
def test_novelty_one(self) -> None:
record = _make_v3_record()
record.novelty_score = 1.0
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(record)
assert v2.novelty_score == 1.0
# ---------------------------------------------------------------------------
# Task 22.3: Golden mapping tests — confidence range
# ---------------------------------------------------------------------------
class TestConfidenceRange:
"""confidence stays in [0, 1]."""
def test_confidence_passes_through(self) -> None:
record = _make_v3_record()
record.confidence = 0.85
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(record)
assert v2.confidence == 0.85
assert 0.0 <= v2.confidence <= 1.0
def test_confidence_zero(self) -> None:
record = _make_v3_record()
record.confidence = 0.0
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(record)
assert v2.confidence == 0.0
def test_confidence_one(self) -> None:
record = _make_v3_record()
record.confidence = 1.0
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(record)
assert v2.confidence == 1.0
# ---------------------------------------------------------------------------
# Task 22.3: Golden mapping tests — catalyst type mapping
# ---------------------------------------------------------------------------
class TestCatalystTypeMapping:
"""Event taxonomy maps to legacy catalyst enum values."""
@pytest.mark.parametrize(
"event_class,expected_catalyst",
[
("earnings_beat", "earnings"),
("earnings_miss", "earnings"),
("guidance_raise", "earnings"),
("guidance_cut", "earnings"),
("product_launch", "product"),
("legal_regulatory", "legal"),
("ma_announcement", "m_and_a"),
("supply_chain", "supply_chain"),
("rating_change", "rating_change"),
("macro_event", "macro"),
("management_change", "other"),
("dividend_change", "other"),
("buyback", "other"),
],
)
def test_event_class_to_catalyst(self, event_class: str, expected_catalyst: str) -> None:
signal = _make_signal(event_classes=[event_class])
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].catalyst_type == expected_catalyst
def test_unknown_event_class_falls_back_to_other(self) -> None:
signal = _make_signal(event_classes=["unknown_future_event"])
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].catalyst_type == "other"
def test_empty_event_classes_falls_back_to_other(self) -> None:
signal = _make_signal(event_classes=[])
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].catalyst_type == "other"
def test_first_matching_event_wins(self) -> None:
"""When multiple event classes, first match determines catalyst."""
signal = _make_signal(event_classes=["product_launch", "earnings_beat"])
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=[signal]))
assert v2.impact_records[0].catalyst_type == "product"
# ---------------------------------------------------------------------------
# Task 22.1: Field mapping completeness
# ---------------------------------------------------------------------------
class TestFieldMappingCompleteness:
"""Verify all v2 fields are populated from v3 sources."""
def test_summary_mapped(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record())
assert v2.summary == "Test summary"
def test_macro_themes_mapped(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record())
assert v2.macro_themes == ["earnings", "technology"]
def test_evidence_spans_mapped(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record())
assert v2.impact_records[0].evidence_spans == ["span-1", "span-2"]
def test_relevance_mapped(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record())
assert v2.impact_records[0].relevance == 0.9
def test_ticker_mapped(self) -> None:
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record())
assert v2.impact_records[0].ticker == "AAPL"
def test_multiple_companies(self) -> None:
signals = [
_make_signal(),
V3CompanySignal(
company_id="bbbbbbbb-1111-2222-3333-444444444444",
ticker="MSFT",
relevance_probability=0.7,
event_classes=["product_launch"],
sentiment=V3SentimentDistribution(positive=0.6, negative=0.2, neutral=0.2),
direction_probabilities=V3DirectionProbabilities(positive=0.5, negative=0.2, neutral=0.3),
horizon_probabilities=V3HorizonProbabilities(seven_day=0.6, thirty_day=0.4),
expected_magnitude=0.3,
evidence_spans=["span-3"],
),
]
adapter = CompatibilityAdapter(mode=AdapterMode.REPLAY_ONLY)
v2, _ = adapter.map_to_v2(_make_v3_record(signals=signals))
assert len(v2.impact_records) == 2
tickers = {r.ticker for r in v2.impact_records}
assert tickers == {"AAPL", "MSFT"}
@@ -0,0 +1,521 @@
"""Tests for the confidence feature pipeline.
Covers feature extraction, calibrator fit/predict, conservative defaults,
artifact save/load, and ECE/Brier computation.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import numpy as np
import pytest
from services.intelligence_pipeline_v3.confidence.artifacts import (
list_versions,
load_artifact,
load_metadata,
save_artifact,
)
from services.intelligence_pipeline_v3.confidence.calibrator import (
ConfidenceCalibrator,
_compute_brier,
_compute_ece,
compare_methods,
)
from services.intelligence_pipeline_v3.confidence.defaults import (
get_default_confidence,
is_underrepresented,
)
from services.intelligence_pipeline_v3.confidence.features import (
AgreementStageResult,
ConfidenceFeatureExtractor,
EvidenceStageResult,
ExtractionStageResult,
ResolutionStageResult,
SentimentStageResult,
)
from services.intelligence_pipeline_v3.confidence.models import (
ConfidenceFeatures,
ConfidenceResult,
)
# --- Fixtures ---
def _make_extraction_result(
entity_scores: list[float] | None = None,
relation_scores: list[float] | None = None,
total_facts: int = 10,
valid_numeric_facts: int = 8,
populated_fields: int = 7,
expected_fields: int = 10,
) -> ExtractionStageResult:
return ExtractionStageResult(
entity_scores=[0.9, 0.85, 0.7] if entity_scores is None else entity_scores,
relation_scores=[0.8, 0.75] if relation_scores is None else relation_scores,
total_facts=total_facts,
valid_numeric_facts=valid_numeric_facts,
populated_fields=populated_fields,
expected_fields=expected_fields,
)
def _make_resolution_result(
margins: list[float] | None = None,
) -> ResolutionStageResult:
return ResolutionStageResult(
ambiguity_margins=[0.9, 0.6] if margins is None else margins,
)
def _make_evidence_result(
total: int = 10,
supported: int = 8,
) -> EvidenceStageResult:
return EvidenceStageResult(
total_claims=total,
supported_claims=supported,
)
def _make_sentiment_result(
probs: list[float] | None = None,
) -> SentimentStageResult:
return SentimentStageResult(
max_class_probabilities=[0.85, 0.9] if probs is None else probs,
calibration_version="v1.0",
)
def _make_agreement_result() -> AgreementStageResult:
return AgreementStageResult(
agreement_ratio=0.8,
novelty_certainty=0.7,
hard_case_score=0.2,
)
def _make_features(
entity_span_score: float = 0.85,
document_type: str = "news",
) -> ConfidenceFeatures:
return ConfidenceFeatures(
entity_span_score=entity_span_score,
alias_resolution_margin=0.75,
numeric_parser_validity=0.8,
evidence_coverage=0.8,
relation_score=0.775,
sentiment_calibration_confidence=0.875,
cross_stage_agreement=0.8,
duplicate_novelty_certainty=0.7,
document_completeness=0.7,
document_type=document_type,
known_hard_case_patterns=0.2,
)
def _generate_training_data(
n_samples: int = 100,
seed: int = 42,
) -> tuple[list[ConfidenceFeatures], list[bool]]:
"""Generate synthetic training data for calibrator tests."""
rng = np.random.default_rng(seed)
features = []
labels = []
doc_types = ["news", "filing", "transcript", "press_release", "macro_event"]
for _ in range(n_samples):
# Generate features with some correlation to the label
base_quality = rng.uniform(0.3, 0.95)
noise = rng.normal(0, 0.1)
f = ConfidenceFeatures(
entity_span_score=float(np.clip(base_quality + rng.normal(0, 0.1), 0, 1)),
alias_resolution_margin=float(np.clip(base_quality + rng.normal(0, 0.15), 0, 1)),
numeric_parser_validity=float(np.clip(base_quality + rng.normal(0, 0.1), 0, 1)),
evidence_coverage=float(np.clip(base_quality + rng.normal(0, 0.1), 0, 1)),
relation_score=float(np.clip(base_quality + rng.normal(0, 0.15), 0, 1)),
sentiment_calibration_confidence=float(np.clip(base_quality + rng.normal(0, 0.1), 0, 1)),
cross_stage_agreement=float(np.clip(base_quality + rng.normal(0, 0.1), 0, 1)),
duplicate_novelty_certainty=float(np.clip(base_quality + rng.normal(0, 0.15), 0, 1)),
document_completeness=float(np.clip(base_quality + rng.normal(0, 0.1), 0, 1)),
document_type=rng.choice(doc_types),
known_hard_case_patterns=float(np.clip(rng.uniform(0, 0.5), 0, 1)),
)
features.append(f)
# Label correlates with base quality
label = bool(rng.random() < (base_quality + noise))
labels.append(label)
return features, labels
# --- Test Feature Extraction ---
class TestFeatureExtraction:
"""Test that feature extraction produces valid feature vectors."""
def test_extract_features_produces_valid_vector(self):
"""Feature extraction from all stages produces a valid ConfidenceFeatures."""
extractor = ConfidenceFeatureExtractor()
features = extractor.extract_features(
extraction_result=_make_extraction_result(),
resolution_result=_make_resolution_result(),
evidence_result=_make_evidence_result(),
sentiment_result=_make_sentiment_result(),
agreement_result=_make_agreement_result(),
document_type="news",
)
assert isinstance(features, ConfidenceFeatures)
assert 0.0 <= features.entity_span_score <= 1.0
assert 0.0 <= features.alias_resolution_margin <= 1.0
assert 0.0 <= features.numeric_parser_validity <= 1.0
assert 0.0 <= features.evidence_coverage <= 1.0
assert 0.0 <= features.relation_score <= 1.0
assert 0.0 <= features.sentiment_calibration_confidence <= 1.0
assert 0.0 <= features.cross_stage_agreement <= 1.0
assert 0.0 <= features.duplicate_novelty_certainty <= 1.0
assert 0.0 <= features.document_completeness <= 1.0
assert 0.0 <= features.known_hard_case_patterns <= 1.0
assert features.document_type == "news"
def test_extract_features_without_agreement(self):
"""Feature extraction uses sensible defaults when agreement is not available."""
extractor = ConfidenceFeatureExtractor()
features = extractor.extract_features(
extraction_result=_make_extraction_result(),
resolution_result=_make_resolution_result(),
evidence_result=_make_evidence_result(),
sentiment_result=_make_sentiment_result(),
agreement_result=None,
document_type="filing",
)
assert features.cross_stage_agreement == 0.5
assert features.duplicate_novelty_certainty == 0.5
assert features.known_hard_case_patterns == 0.0
def test_extract_features_empty_entities(self):
"""Feature extraction handles empty entity scores gracefully."""
extractor = ConfidenceFeatureExtractor()
features = extractor.extract_features(
extraction_result=_make_extraction_result(entity_scores=[]),
resolution_result=_make_resolution_result(),
evidence_result=_make_evidence_result(),
sentiment_result=_make_sentiment_result(),
)
assert features.entity_span_score == 0.0
def test_extract_features_no_claims(self):
"""Feature extraction handles zero claims gracefully."""
extractor = ConfidenceFeatureExtractor()
features = extractor.extract_features(
extraction_result=_make_extraction_result(),
resolution_result=_make_resolution_result(),
evidence_result=_make_evidence_result(total=0, supported=0),
sentiment_result=_make_sentiment_result(),
)
assert features.evidence_coverage == 0.0
def test_to_vector_produces_correct_length(self):
"""Feature vector has expected dimensionality."""
features = _make_features()
vector = features.to_vector()
assert len(vector) == 11
assert all(isinstance(v, float) for v in vector)
def test_unknown_document_type_defaults(self):
"""Unknown document types are normalized to 'unknown'."""
extractor = ConfidenceFeatureExtractor()
features = extractor.extract_features(
extraction_result=_make_extraction_result(),
resolution_result=_make_resolution_result(),
evidence_result=_make_evidence_result(),
sentiment_result=_make_sentiment_result(),
document_type="exotic_type",
)
assert features.document_type == "unknown"
# --- Test Calibrator ---
class TestCalibrator:
"""Test calibrator fit/predict roundtrip and method comparison."""
def test_fit_predict_isotonic(self):
"""Isotonic calibrator can fit and produce predictions in [0, 1]."""
features, labels = _generate_training_data(n_samples=50)
cal = ConfidenceCalibrator(method="isotonic")
cal.fit(features, labels, version="test-v1")
assert cal.is_fitted
assert cal.version == "test-v1"
prediction = cal.predict(features[0])
assert 0.0 <= prediction <= 1.0
def test_fit_predict_platt(self):
"""Platt calibrator can fit and produce predictions in [0, 1]."""
features, labels = _generate_training_data(n_samples=50)
cal = ConfidenceCalibrator(method="platt")
cal.fit(features, labels, version="test-v1")
assert cal.is_fitted
prediction = cal.predict(features[0])
assert 0.0 <= prediction <= 1.0
def test_unfitted_returns_neutral(self):
"""Unfitted calibrator returns 0.5 as neutral default."""
cal = ConfidenceCalibrator()
features = _make_features()
prediction = cal.predict(features)
assert prediction == 0.5
def test_predict_batch(self):
"""Batch prediction returns correct number of results."""
features, labels = _generate_training_data(n_samples=50)
cal = ConfidenceCalibrator(method="isotonic")
cal.fit(features, labels)
batch_predictions = cal.predict_batch(features[:10])
assert len(batch_predictions) == 10
assert all(0.0 <= p <= 1.0 for p in batch_predictions)
def test_fit_empty_raises(self):
"""Fitting with empty data raises ValueError."""
cal = ConfidenceCalibrator()
with pytest.raises(ValueError, match="must not be empty"):
cal.fit([], [])
def test_fit_mismatched_lengths_raises(self):
"""Fitting with mismatched lengths raises ValueError."""
features, labels = _generate_training_data(n_samples=10)
cal = ConfidenceCalibrator()
with pytest.raises(ValueError, match="must have the same length"):
cal.fit(features, labels[:5])
def test_metadata_after_fit(self):
"""Metadata is populated after fitting."""
features, labels = _generate_training_data(n_samples=50)
cal = ConfidenceCalibrator(method="isotonic")
cal.fit(features, labels, version="v1.0.0", training_range="2024-01-01 to 2024-06-30")
assert cal.metadata is not None
assert cal.metadata.version == "v1.0.0"
assert cal.metadata.method == "isotonic"
assert cal.metadata.training_count == 50
assert cal.metadata.training_range == "2024-01-01 to 2024-06-30"
assert 0.0 <= cal.metadata.ece <= 1.0
assert 0.0 <= cal.metadata.brier_score <= 1.0
def test_compare_methods(self):
"""Method comparison returns ECE and Brier for both methods."""
features, labels = _generate_training_data(n_samples=50)
results = compare_methods(features, labels, n_folds=3)
assert "isotonic" in results
assert "platt" in results
assert "ece" in results["isotonic"]
assert "brier" in results["isotonic"]
assert "ece" in results["platt"]
assert "brier" in results["platt"]
# --- Test ECE and Brier ---
class TestMetrics:
"""Test ECE and Brier score computation."""
def test_ece_perfect_calibration(self):
"""ECE is 0 for perfectly calibrated predictions."""
# Perfect: predict 1.0 for positives, 0.0 for negatives
predictions = np.array([1.0, 1.0, 0.0, 0.0, 1.0])
labels = np.array([1.0, 1.0, 0.0, 0.0, 1.0])
ece = _compute_ece(predictions, labels)
assert ece == pytest.approx(0.0, abs=1e-10)
def test_ece_worst_calibration(self):
"""ECE is high for badly calibrated predictions."""
# Predict 1.0 but all are actually 0
predictions = np.array([0.9, 0.9, 0.9, 0.9, 0.9])
labels = np.array([0.0, 0.0, 0.0, 0.0, 0.0])
ece = _compute_ece(predictions, labels)
assert ece > 0.5
def test_brier_perfect_predictions(self):
"""Brier score is 0 for perfect predictions."""
predictions = np.array([1.0, 0.0, 1.0, 0.0])
labels = np.array([1.0, 0.0, 1.0, 0.0])
brier = _compute_brier(predictions, labels)
assert brier == pytest.approx(0.0, abs=1e-10)
def test_brier_worst_predictions(self):
"""Brier score is 1 for worst possible predictions."""
predictions = np.array([1.0, 1.0, 0.0, 0.0])
labels = np.array([0.0, 0.0, 1.0, 1.0])
brier = _compute_brier(predictions, labels)
assert brier == pytest.approx(1.0, abs=1e-10)
def test_brier_uniform_predictions(self):
"""Brier score for uniform 0.5 predictions against balanced labels is 0.25."""
predictions = np.array([0.5, 0.5, 0.5, 0.5])
labels = np.array([1.0, 0.0, 1.0, 0.0])
brier = _compute_brier(predictions, labels)
assert brier == pytest.approx(0.25, abs=1e-10)
def test_ece_empty_returns_zero(self):
"""ECE of empty arrays is 0."""
ece = _compute_ece(np.array([]), np.array([]))
assert ece == 0.0
def test_brier_empty_returns_zero(self):
"""Brier of empty arrays is 0."""
brier = _compute_brier(np.array([]), np.array([]))
assert brier == 0.0
# --- Test Conservative Defaults ---
class TestDefaults:
"""Test conservative defaults for underrepresented classes."""
def test_known_document_type(self):
"""Known document types return conservative probabilities in [0.3, 0.5]."""
result = get_default_confidence("news", "earnings_beat")
assert isinstance(result, ConfidenceResult)
assert 0.3 <= result.probability <= 0.5
assert result.under_calibrated is True
assert result.is_calibrated is False
assert "conservative-default" in result.calibration_version
def test_unknown_document_type(self):
"""Unknown document types return the most conservative default (0.3)."""
result = get_default_confidence("exotic_type", "unknown_event")
assert result.probability == 0.3
assert result.under_calibrated is True
def test_unknown_event_class(self):
"""Unknown event classes use the lowest default."""
result = get_default_confidence("news", "never_seen_before")
assert result.probability == 0.30
assert result.under_calibrated is True
def test_all_document_types_conservative(self):
"""All defined document types have defaults in [0.3, 0.5]."""
doc_types = ["news", "filing", "transcript", "press_release", "macro_event", "unknown"]
for dt in doc_types:
result = get_default_confidence(dt, "earnings_beat")
assert 0.3 <= result.probability <= 0.5, f"Failed for {dt}"
def test_is_underrepresented_no_counts(self):
"""Without known counts, unknown types are underrepresented."""
assert is_underrepresented("exotic", "unknown_event") is True
assert is_underrepresented("news", "earnings_beat") is False
def test_is_underrepresented_with_counts(self):
"""With known counts, low-count classes are underrepresented."""
counts = {("news", "earnings_beat"): 100, ("filing", "merger"): 5}
assert is_underrepresented("news", "earnings_beat", known_counts=counts) is False
assert is_underrepresented("filing", "merger", known_counts=counts) is True
assert is_underrepresented("news", "unknown", known_counts=counts) is True
# --- Test Artifact Save/Load ---
class TestArtifacts:
"""Test calibration artifact persistence."""
def test_save_load_roundtrip(self):
"""Save and load preserves calibrator state."""
features, labels = _generate_training_data(n_samples=50)
cal = ConfidenceCalibrator(method="isotonic")
cal.fit(features, labels, version="v1.0.0", training_range="test")
with tempfile.TemporaryDirectory() as tmpdir:
save_artifact(cal, "v1.0.0", tmpdir)
loaded = load_artifact(Path(tmpdir) / "v1.0.0")
assert loaded.is_fitted
assert loaded.version == "v1.0.0"
assert loaded.method == "isotonic"
# Predictions should match
test_features = _make_features()
original_pred = cal.predict(test_features)
loaded_pred = loaded.predict(test_features)
assert original_pred == pytest.approx(loaded_pred, abs=1e-10)
def test_save_unfitted_raises(self):
"""Saving an unfitted calibrator raises ValueError."""
cal = ConfidenceCalibrator()
with tempfile.TemporaryDirectory() as tmpdir:
with pytest.raises(ValueError, match="unfitted"):
save_artifact(cal, "v1.0.0", tmpdir)
def test_load_nonexistent_raises(self):
"""Loading from a missing path raises FileNotFoundError."""
with pytest.raises(FileNotFoundError):
load_artifact("/nonexistent/path")
def test_load_metadata(self):
"""Metadata can be loaded independently."""
features, labels = _generate_training_data(n_samples=50)
cal = ConfidenceCalibrator(method="platt")
cal.fit(features, labels, version="v2.0.0", training_range="2024-01-01 to 2024-12-31")
with tempfile.TemporaryDirectory() as tmpdir:
save_artifact(cal, "v2.0.0", tmpdir)
metadata = load_metadata(Path(tmpdir) / "v2.0.0")
assert metadata.version == "v2.0.0"
assert metadata.method == "platt"
assert metadata.training_count == 50
def test_list_versions(self):
"""list_versions finds all saved artifact versions."""
features, labels = _generate_training_data(n_samples=50)
with tempfile.TemporaryDirectory() as tmpdir:
for version in ["v1.0.0", "v1.1.0", "v2.0.0"]:
cal = ConfidenceCalibrator(method="isotonic")
cal.fit(features, labels, version=version)
save_artifact(cal, version, tmpdir)
versions = list_versions(tmpdir)
assert versions == ["v1.0.0", "v1.1.0", "v2.0.0"]
def test_list_versions_empty_dir(self):
"""list_versions returns empty list for empty or missing directory."""
with tempfile.TemporaryDirectory() as tmpdir:
assert list_versions(tmpdir) == []
assert list_versions("/nonexistent") == []
@@ -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
@@ -0,0 +1,194 @@
"""Tests for the inter-annotator agreement metrics."""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.gold_corpus.agreement import (
AgreementThresholds,
FieldAgreement,
InterAnnotatorReport,
compute_cohens_kappa,
compute_weighted_kappa,
)
# ---------------------------------------------------------------------------
# Tests — compute_cohens_kappa
# ---------------------------------------------------------------------------
class TestCohensKappa:
def test_perfect_agreement(self) -> None:
a = ["pos", "neg", "neutral", "pos", "neg"]
b = ["pos", "neg", "neutral", "pos", "neg"]
kappa = compute_cohens_kappa(a, b)
assert abs(kappa - 1.0) < 1e-10
def test_no_agreement_beyond_chance(self) -> None:
# If both annotators use the same marginal distribution but disagree
# on specific items, kappa should be around 0
a = ["pos"] * 50 + ["neg"] * 50
b = ["neg"] * 50 + ["pos"] * 50
kappa = compute_cohens_kappa(a, b)
assert kappa < 0.0 # Worse than chance
def test_moderate_agreement(self) -> None:
# 80% agreement with 2 categories
a = ["pos", "pos", "neg", "pos", "neg", "neg", "pos", "pos", "neg", "pos"]
b = ["pos", "pos", "neg", "pos", "neg", "neg", "pos", "neg", "neg", "pos"]
kappa = compute_cohens_kappa(a, b)
# Should be positive but less than 1
assert 0.0 < kappa < 1.0
def test_raises_on_empty(self) -> None:
with pytest.raises(ValueError, match="empty"):
compute_cohens_kappa([], [])
def test_raises_on_length_mismatch(self) -> None:
with pytest.raises(ValueError, match="equal length"):
compute_cohens_kappa(["a", "b"], ["a"])
def test_single_category_returns_one(self) -> None:
# All same category = trivially perfect
a = ["pos", "pos", "pos"]
b = ["pos", "pos", "pos"]
kappa = compute_cohens_kappa(a, b)
assert abs(kappa - 1.0) < 1e-10
def test_known_kappa_value(self) -> None:
# Classic example: 2 raters, 2 categories, known kappa
# 50 items: 20 agree pos, 15 agree neg, 10 A=pos B=neg, 5 A=neg B=pos
a = ["pos"] * 20 + ["neg"] * 15 + ["pos"] * 10 + ["neg"] * 5
b = ["pos"] * 20 + ["neg"] * 15 + ["neg"] * 10 + ["pos"] * 5
kappa = compute_cohens_kappa(a, b)
# p_o = 35/50 = 0.7
# p_e = (30/50 * 25/50) + (20/50 * 25/50) = 0.3 + 0.2 = 0.5
# kappa = (0.7 - 0.5) / (1 - 0.5) = 0.4
assert abs(kappa - 0.4) < 1e-10
# ---------------------------------------------------------------------------
# Tests — compute_weighted_kappa
# ---------------------------------------------------------------------------
class TestWeightedKappa:
def test_perfect_agreement(self) -> None:
a = ["low", "medium", "high", "low", "medium"]
b = ["low", "medium", "high", "low", "medium"]
kappa = compute_weighted_kappa(a, b, ["low", "medium", "high"])
assert abs(kappa - 1.0) < 1e-10
def test_adjacent_disagreement_less_penalized_than_distant(self) -> None:
# Mix of agreement and disagreement where adjacent is closer
a = ["low", "medium", "high", "low", "medium", "high", "low", "medium"]
# Adjacent disagreements (off by 1)
b_adjacent = ["medium", "medium", "high", "medium", "medium", "high", "medium", "medium"]
# Distant disagreements (off by 2)
b_distant = ["high", "medium", "high", "high", "medium", "high", "high", "medium"]
categories = ["low", "medium", "high"]
kappa_adjacent = compute_weighted_kappa(a, b_adjacent, categories)
kappa_distant = compute_weighted_kappa(a, b_distant, categories)
# Adjacent disagreement should give higher kappa (less penalty)
assert kappa_adjacent > kappa_distant
def test_linear_vs_quadratic(self) -> None:
a = ["low", "low", "medium", "high", "high"]
b = ["medium", "high", "medium", "low", "medium"]
categories = ["low", "medium", "high"]
linear = compute_weighted_kappa(a, b, categories, weight_type="linear")
quadratic = compute_weighted_kappa(a, b, categories, weight_type="quadratic")
# Both should be numbers, quadratic penalizes large distances more
assert isinstance(linear, float)
assert isinstance(quadratic, float)
def test_raises_on_empty(self) -> None:
with pytest.raises(ValueError, match="empty"):
compute_weighted_kappa([], [])
def test_raises_on_length_mismatch(self) -> None:
with pytest.raises(ValueError, match="equal length"):
compute_weighted_kappa(["a", "b"], ["a"])
def test_raises_on_invalid_weight_type(self) -> None:
with pytest.raises(ValueError, match="weight_type"):
compute_weighted_kappa(["a"], ["a"], weight_type="cubic")
def test_raises_on_unknown_category(self) -> None:
with pytest.raises(ValueError, match="not in ordered_categories"):
compute_weighted_kappa(
["a", "b"], ["a", "c"], ordered_categories=["a", "b"]
)
def test_auto_determines_categories(self) -> None:
a = ["high", "low", "medium"]
b = ["high", "medium", "medium"]
# Should not raise when ordered_categories is None
kappa = compute_weighted_kappa(a, b)
assert isinstance(kappa, float)
def test_single_category_returns_one(self) -> None:
kappa = compute_weighted_kappa(["a", "a"], ["a", "a"], ["a"])
assert abs(kappa - 1.0) < 1e-10
# ---------------------------------------------------------------------------
# Tests — AgreementThresholds
# ---------------------------------------------------------------------------
class TestAgreementThresholds:
def test_default_thresholds(self) -> None:
thresholds = AgreementThresholds()
assert thresholds.entities == 0.80
assert thresholds.events == 0.80
assert thresholds.relations == 0.70
assert thresholds.sentiment == 0.70
def test_custom_thresholds(self) -> None:
thresholds = AgreementThresholds(entities=0.90, sentiment=0.75)
assert thresholds.entities == 0.90
assert thresholds.sentiment == 0.75
# ---------------------------------------------------------------------------
# Tests — InterAnnotatorReport
# ---------------------------------------------------------------------------
class TestInterAnnotatorReport:
def test_report_construction(self) -> None:
fields = [
FieldAgreement(
field_name="entities",
kappa=0.85,
threshold=0.80,
meets_threshold=True,
n_items=100,
agreement_rate=0.90,
),
FieldAgreement(
field_name="relations",
kappa=0.65,
threshold=0.70,
meets_threshold=False,
n_items=50,
agreement_rate=0.72,
),
]
report = InterAnnotatorReport(
annotator_a="annotator_1",
annotator_b="annotator_2",
n_documents=25,
field_agreements=fields,
overall_kappa=0.75,
all_thresholds_met=False,
)
assert report.n_documents == 25
assert not report.all_thresholds_met
assert report.field_agreements[0].meets_threshold
assert not report.field_agreements[1].meets_threshold
@@ -0,0 +1,281 @@
"""Tests for the Gold Corpus sampling framework."""
from __future__ import annotations
import uuid
import pytest
from services.intelligence_pipeline_v3.gold_corpus.sampler import (
CompanyCountBucket,
CorpusSamplingConfig,
CoverageReport,
Difficulty,
DiversityRequirements,
DiversityTag,
DocumentMetadata,
LengthBucket,
SourceType,
StratificationDimensions,
sample_corpus,
validate_corpus_coverage,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_doc(
document_type: str = "article",
event_class: str | None = "earnings_beat",
length_bucket: LengthBucket = LengthBucket.MEDIUM,
source_type: SourceType = SourceType.NEWS,
company_count_bucket: CompanyCountBucket = CompanyCountBucket.SINGLE,
difficulty: Difficulty = Difficulty.EASY,
diversity_tags: list[DiversityTag] | None = None,
) -> DocumentMetadata:
return DocumentMetadata(
document_id=str(uuid.uuid4()),
document_type=document_type,
event_class=event_class,
length_bucket=length_bucket,
source_type=source_type,
company_count_bucket=company_count_bucket,
difficulty=difficulty,
diversity_tags=diversity_tags or [],
)
def _generate_diverse_pool(size: int = 3000) -> list[DocumentMetadata]:
"""Generate a large diverse pool covering all strata."""
import random
rng = random.Random(123)
pool: list[DocumentMetadata] = []
doc_types = ["article", "filing", "transcript", "press_release", "macro_event"]
event_classes = [
"earnings_beat", "earnings_miss", "guidance_raise", "guidance_cut",
"ma_announcement", "legal_regulatory", "product_launch", "supply_chain",
"rating_change", "management_change", "macro_event", "dividend_change", "buyback",
]
lengths = list(LengthBucket)
sources = list(SourceType)
company_counts = list(CompanyCountBucket)
difficulties = list(Difficulty)
tags = list(DiversityTag)
for _ in range(size):
doc_type = rng.choice(doc_types)
source = SourceType(doc_type) if doc_type in [s.value for s in SourceType] else rng.choice(sources)
doc_tags: list[DiversityTag] = []
if rng.random() < 0.15:
doc_tags.append(rng.choice(tags))
if doc_type == "transcript":
doc_tags.append(DiversityTag.TRANSCRIPT)
if doc_type == "macro_event":
doc_tags.append(DiversityTag.MACRO_EVENT)
pool.append(
DocumentMetadata(
document_id=str(uuid.uuid4()),
document_type=doc_type,
event_class=rng.choice(event_classes),
length_bucket=rng.choice(lengths),
source_type=source,
company_count_bucket=rng.choice(company_counts),
difficulty=rng.choice(difficulties),
diversity_tags=doc_tags,
)
)
# Ensure we have enough documents with specific diversity tags
for tag in tags:
for _ in range(60):
pool.append(
DocumentMetadata(
document_id=str(uuid.uuid4()),
document_type=rng.choice(doc_types),
event_class=rng.choice(event_classes),
length_bucket=rng.choice(lengths),
source_type=rng.choice(sources),
company_count_bucket=rng.choice(company_counts),
difficulty=rng.choice(difficulties),
diversity_tags=[tag],
)
)
return pool
# ---------------------------------------------------------------------------
# Tests — CorpusSamplingConfig
# ---------------------------------------------------------------------------
class TestCorpusSamplingConfig:
def test_default_config_valid(self) -> None:
config = CorpusSamplingConfig()
assert config.target_size == 1000
assert config.random_seed == 42
def test_custom_target_size(self) -> None:
config = CorpusSamplingConfig(target_size=500)
assert config.target_size == 500
def test_minimum_target_size(self) -> None:
with pytest.raises(Exception):
CorpusSamplingConfig(target_size=50)
# ---------------------------------------------------------------------------
# Tests — sample_corpus
# ---------------------------------------------------------------------------
class TestSampleCorpus:
def test_returns_at_least_target_size(self) -> None:
pool = _generate_diverse_pool(3000)
config = CorpusSamplingConfig(target_size=1000)
result = sample_corpus(pool, config)
assert len(result) >= 1000
def test_no_duplicate_documents(self) -> None:
pool = _generate_diverse_pool(3000)
result = sample_corpus(pool)
ids = [d.document_id for d in result]
assert len(ids) == len(set(ids))
def test_deterministic_with_same_seed(self) -> None:
pool = _generate_diverse_pool(2000)
config = CorpusSamplingConfig(random_seed=99)
result1 = sample_corpus(pool, config)
result2 = sample_corpus(pool, config)
assert [d.document_id for d in result1] == [d.document_id for d in result2]
def test_different_seed_gives_different_sample(self) -> None:
pool = _generate_diverse_pool(2000)
result1 = sample_corpus(pool, CorpusSamplingConfig(random_seed=1))
result2 = sample_corpus(pool, CorpusSamplingConfig(random_seed=2))
ids1 = set(d.document_id for d in result1)
ids2 = set(d.document_id for d in result2)
# They should differ (not guaranteed to be entirely different, but should overlap less than 100%)
assert ids1 != ids2
def test_diversity_tags_represented(self) -> None:
pool = _generate_diverse_pool(3000)
config = CorpusSamplingConfig(target_size=1000)
result = sample_corpus(pool, config)
# Check diversity tags are present
all_tags: set[DiversityTag] = set()
for doc in result:
all_tags.update(doc.diversity_tags)
# All diversity tag types should be represented
for tag in DiversityTag:
assert tag in all_tags, f"Diversity tag {tag} not represented in sample"
def test_small_pool_returns_all(self) -> None:
pool = [_make_doc() for _ in range(50)]
config = CorpusSamplingConfig(target_size=100)
result = sample_corpus(pool, config)
# Should return everything available from the pool
assert len(result) <= len(pool)
def test_all_document_types_represented(self) -> None:
pool = _generate_diverse_pool(3000)
result = sample_corpus(pool)
doc_types = {d.document_type for d in result}
assert "article" in doc_types
assert "filing" in doc_types
assert "transcript" in doc_types
assert "press_release" in doc_types
assert "macro_event" in doc_types
def test_all_difficulty_levels_represented(self) -> None:
pool = _generate_diverse_pool(3000)
result = sample_corpus(pool)
difficulties = {d.difficulty for d in result}
assert Difficulty.EASY in difficulties
assert Difficulty.MEDIUM in difficulties
assert Difficulty.HARD in difficulties
# ---------------------------------------------------------------------------
# Tests — validate_corpus_coverage
# ---------------------------------------------------------------------------
class TestValidateCorpusCoverage:
def test_valid_corpus_passes(self) -> None:
pool = _generate_diverse_pool(3000)
config = CorpusSamplingConfig(target_size=1000)
corpus = sample_corpus(pool, config)
report = validate_corpus_coverage(corpus, config)
# The report should have reasonable coverage
assert report.total_documents >= 1000
assert isinstance(report, CoverageReport)
def test_empty_corpus_fails(self) -> None:
config = CorpusSamplingConfig(target_size=100)
report = validate_corpus_coverage([], config)
assert not report.is_valid
assert not report.meets_target_size
def test_reports_dimension_gaps(self) -> None:
# Create a corpus missing some document types
docs = [_make_doc(document_type="article") for _ in range(100)]
config = CorpusSamplingConfig(target_size=100)
report = validate_corpus_coverage(docs, config)
# Should report gaps for missing document types
assert "document_type" in report.dimension_gaps
assert "filing" in report.dimension_gaps["document_type"]
def test_reports_diversity_gaps(self) -> None:
# Create a corpus without diversity tags
docs = [_make_doc() for _ in range(100)]
config = CorpusSamplingConfig(target_size=100)
report = validate_corpus_coverage(docs, config)
assert report.diversity_gaps # Should have gaps
# ---------------------------------------------------------------------------
# Tests — StratificationDimensions
# ---------------------------------------------------------------------------
class TestStratificationDimensions:
def test_default_dimensions_cover_all_types(self) -> None:
dims = StratificationDimensions()
assert "article" in dims.document_type
assert "filing" in dims.document_type
assert "short" in dims.length_bucket
assert "medium" in dims.length_bucket
assert "long" in dims.length_bucket
def test_all_event_classes_have_minimums(self) -> None:
dims = StratificationDimensions()
assert len(dims.event_class) == 13 # All EventClass values
# ---------------------------------------------------------------------------
# Tests — DiversityRequirements
# ---------------------------------------------------------------------------
class TestDiversityRequirements:
def test_default_requirements(self) -> None:
req = DiversityRequirements()
assert req.duplicate_story >= 1
assert req.long_filing >= 1
assert req.contradictory_reports >= 1
def test_as_tag_minimums(self) -> None:
req = DiversityRequirements()
tag_mins = req.as_tag_minimums()
assert DiversityTag.DUPLICATE_STORY in tag_mins
assert DiversityTag.MACRO_EVENT in tag_mins
assert all(v > 0 for v in tag_mins.values())
@@ -0,0 +1,300 @@
"""Tests for the Gold Corpus split management."""
from __future__ import annotations
import hashlib
import json
import uuid
import pytest
from services.intelligence_pipeline_v3.gold_corpus.sampler import (
CompanyCountBucket,
Difficulty,
DocumentMetadata,
LengthBucket,
SourceType,
)
from services.intelligence_pipeline_v3.gold_corpus.splits import (
CorpusSplit,
SplitConfig,
SplitManifest,
create_splits,
freeze_holdout,
select_hard_cases,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_doc(difficulty: Difficulty = Difficulty.EASY) -> DocumentMetadata:
return DocumentMetadata(
document_id=str(uuid.uuid4()),
document_type="article",
event_class="earnings_beat",
length_bucket=LengthBucket.MEDIUM,
source_type=SourceType.NEWS,
company_count_bucket=CompanyCountBucket.SINGLE,
difficulty=difficulty,
)
def _make_corpus(size: int = 200) -> list[DocumentMetadata]:
"""Generate a mixed-difficulty corpus."""
import random
rng = random.Random(42)
difficulties = [Difficulty.EASY, Difficulty.MEDIUM, Difficulty.HARD]
return [_make_doc(rng.choice(difficulties)) for _ in range(size)]
# ---------------------------------------------------------------------------
# Tests — SplitConfig
# ---------------------------------------------------------------------------
class TestSplitConfig:
def test_default_ratios_sum_to_one(self) -> None:
config = SplitConfig()
assert config.validate_ratios()
def test_invalid_ratios_detected(self) -> None:
config = SplitConfig(
train_ratio=0.5,
calibration_ratio=0.5,
holdout_ratio=0.5,
agreement_ratio=0.1,
)
assert not config.validate_ratios()
def test_custom_ratios(self) -> None:
config = SplitConfig(
train_ratio=0.70,
calibration_ratio=0.10,
holdout_ratio=0.15,
agreement_ratio=0.05,
)
assert config.validate_ratios()
# ---------------------------------------------------------------------------
# Tests — create_splits
# ---------------------------------------------------------------------------
class TestCreateSplits:
def test_all_splits_present(self) -> None:
corpus = _make_corpus(200)
splits = create_splits(corpus)
assert CorpusSplit.TRAIN in splits
assert CorpusSplit.CALIBRATION in splits
assert CorpusSplit.HOLDOUT in splits
assert CorpusSplit.ANNOTATOR_AGREEMENT in splits
def test_all_documents_assigned(self) -> None:
corpus = _make_corpus(200)
splits = create_splits(corpus)
total = sum(s.total_count for s in splits.values())
assert total == len(corpus)
def test_no_overlap_between_splits(self) -> None:
corpus = _make_corpus(200)
splits = create_splits(corpus)
all_ids: list[str] = []
for manifest in splits.values():
all_ids.extend(manifest.document_ids)
assert len(all_ids) == len(set(all_ids))
def test_holdout_is_frozen(self) -> None:
corpus = _make_corpus(200)
splits = create_splits(corpus)
holdout = splits[CorpusSplit.HOLDOUT]
assert holdout.frozen is True
assert holdout.frozen_at is not None
assert "prompt_tuning" in holdout.restricted_uses
assert "model_training" in holdout.restricted_uses
def test_approximate_split_ratios(self) -> None:
corpus = _make_corpus(1000)
config = SplitConfig()
splits = create_splits(corpus, config)
total = len(corpus)
# Allow 5% tolerance on ratios
train_ratio = splits[CorpusSplit.TRAIN].total_count / total
holdout_ratio = splits[CorpusSplit.HOLDOUT].total_count / total
assert 0.50 < train_ratio < 0.70
assert 0.15 < holdout_ratio < 0.25
def test_agreement_subset_prefers_hard_cases(self) -> None:
# Create corpus with known difficulty distribution
easy = [_make_doc(Difficulty.EASY) for _ in range(150)]
hard = [_make_doc(Difficulty.HARD) for _ in range(50)]
corpus = easy + hard
config = SplitConfig(hard_case_priority_for_agreement=True)
splits = create_splits(corpus, config)
agreement = splits[CorpusSplit.ANNOTATOR_AGREEMENT]
# The agreement subset should contain hard cases
agreement_ids = set(agreement.document_ids)
hard_ids = {d.document_id for d in hard}
hard_in_agreement = agreement_ids & hard_ids
# Most of the agreement subset should be hard cases
assert len(hard_in_agreement) > 0
def test_deterministic_splits(self) -> None:
corpus = _make_corpus(200)
config = SplitConfig(random_seed=42)
splits1 = create_splits(corpus, config)
splits2 = create_splits(corpus, config)
for split in CorpusSplit:
assert splits1[split].document_ids == splits2[split].document_ids
def test_empty_corpus_raises(self) -> None:
with pytest.raises(ValueError, match="empty corpus"):
create_splits([])
def test_invalid_ratios_raises(self) -> None:
corpus = _make_corpus(100)
config = SplitConfig(
train_ratio=0.5,
calibration_ratio=0.5,
holdout_ratio=0.5,
agreement_ratio=0.5,
)
with pytest.raises(ValueError, match="sum to 1.0"):
create_splits(corpus, config)
# ---------------------------------------------------------------------------
# Tests — select_hard_cases
# ---------------------------------------------------------------------------
class TestSelectHardCases:
def test_returns_hard_difficulty_docs(self) -> None:
easy = [_make_doc(Difficulty.EASY) for _ in range(50)]
hard = [_make_doc(Difficulty.HARD) for _ in range(20)]
corpus = easy + hard
result = select_hard_cases(corpus)
assert len(result) == 20
assert all(d.difficulty == Difficulty.HARD for d in result)
def test_respects_max_count(self) -> None:
hard = [_make_doc(Difficulty.HARD) for _ in range(50)]
result = select_hard_cases(hard, max_count=10)
assert len(result) == 10
def test_empty_if_no_hard_cases(self) -> None:
easy = [_make_doc(Difficulty.EASY) for _ in range(50)]
result = select_hard_cases(easy)
assert len(result) == 0
# ---------------------------------------------------------------------------
# Tests — freeze_holdout
# ---------------------------------------------------------------------------
class TestFreezeHoldout:
def test_produces_valid_json(self) -> None:
manifest = SplitManifest(
split=CorpusSplit.HOLDOUT,
document_ids=["doc-1", "doc-2", "doc-3"],
total_count=3,
)
frozen_json = freeze_holdout(manifest)
parsed = json.loads(frozen_json)
assert parsed["frozen"] is True
assert parsed["total_count"] == 3
assert len(parsed["document_id_hashes"]) == 3
def test_hashes_are_sha256(self) -> None:
doc_ids = ["test-doc-1", "test-doc-2"]
manifest = SplitManifest(
split=CorpusSplit.HOLDOUT,
document_ids=doc_ids,
total_count=2,
)
frozen_json = freeze_holdout(manifest)
parsed = json.loads(frozen_json)
for doc_id, stored_hash in zip(doc_ids, parsed["document_id_hashes"]):
expected = hashlib.sha256(doc_id.encode()).hexdigest()
assert stored_hash == expected
def test_manifest_has_checksum(self) -> None:
manifest = SplitManifest(
split=CorpusSplit.HOLDOUT,
document_ids=["doc-1"],
total_count=1,
)
frozen_json = freeze_holdout(manifest)
parsed = json.loads(frozen_json)
assert "manifest_checksum" in parsed
assert len(parsed["manifest_checksum"]) == 64 # SHA-256 hex
def test_restricted_uses_set(self) -> None:
manifest = SplitManifest(
split=CorpusSplit.HOLDOUT,
document_ids=["doc-1"],
total_count=1,
)
frozen_json = freeze_holdout(manifest)
parsed = json.loads(frozen_json)
assert "prompt_tuning" in parsed["restricted_uses"]
assert "model_training" in parsed["restricted_uses"]
assert "hyperparameter_search" in parsed["restricted_uses"]
def test_rejects_non_holdout_split(self) -> None:
manifest = SplitManifest(
split=CorpusSplit.TRAIN,
document_ids=["doc-1"],
total_count=1,
)
with pytest.raises(ValueError, match="holdout"):
freeze_holdout(manifest)
# ---------------------------------------------------------------------------
# Tests — SplitManifest integrity
# ---------------------------------------------------------------------------
class TestSplitManifest:
def test_verify_integrity_passes(self) -> None:
doc_ids = ["doc-a", "doc-b", "doc-c"]
hashes = [hashlib.sha256(d.encode()).hexdigest() for d in doc_ids]
manifest = SplitManifest(
split=CorpusSplit.HOLDOUT,
document_ids=doc_ids,
document_id_hashes=hashes,
total_count=3,
)
assert manifest.verify_integrity()
def test_verify_integrity_fails_on_tampered_hash(self) -> None:
doc_ids = ["doc-a", "doc-b"]
hashes = [hashlib.sha256(d.encode()).hexdigest() for d in doc_ids]
hashes[1] = "0" * 64 # Tampered
manifest = SplitManifest(
split=CorpusSplit.HOLDOUT,
document_ids=doc_ids,
document_id_hashes=hashes,
total_count=2,
)
assert not manifest.verify_integrity()
def test_verify_integrity_fails_on_length_mismatch(self) -> None:
manifest = SplitManifest(
split=CorpusSplit.HOLDOUT,
document_ids=["doc-a", "doc-b"],
document_id_hashes=["hash-a"],
total_count=2,
)
assert not manifest.verify_integrity()
@@ -0,0 +1,728 @@
"""Tests for the stock-specific impact model layer.
Covers:
- Task 36: Event-time feature snapshots
- Task 37: Outcome labels
- Task 38: Deterministic impact baseline
- Task 39: Trained tabular impact model
- Task 40: Impact output integration
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from services.intelligence_pipeline_v3.impact.baseline import (
EVENT_CLASS_BASE_MAGNITUDE,
EVENT_CLASS_DIRECTION,
DeterministicImpactBaseline,
)
from services.intelligence_pipeline_v3.impact.features import (
ImpactFeatureSet,
clear_feature_snapshots,
get_feature_snapshot,
persist_feature_snapshot,
validate_no_future_leakage,
)
from services.intelligence_pipeline_v3.impact.integration import (
ComparisonMetric,
DirectionProbabilities,
HorizonProbabilities,
ImpactPipelineConfig,
ImpactPredictionOutput,
clear_comparison_metrics,
filter_generative_scores,
get_comparison_metrics,
map_to_legacy_impact,
record_comparison_metric,
)
from services.intelligence_pipeline_v3.impact.labels import (
LABEL_GENERATOR_VERSION,
compute_abnormal_return,
compute_abnormal_volume,
compute_time_to_peak,
generate_outcome_labels,
)
from services.intelligence_pipeline_v3.impact.trained_model import (
ImpactModelTrainer,
TrainingExample,
clear_artifact_registry,
create_walk_forward_splits,
get_approved_model,
get_model_artifact,
register_model_artifact,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
EVENT_TIME = datetime(2024, 6, 15, 14, 30, 0, tzinfo=timezone.utc)
def _make_feature_set(**overrides) -> ImpactFeatureSet:
"""Create a valid ImpactFeatureSet with sensible defaults."""
defaults = {
"event_class_probabilities": {"earnings_beat": 0.7, "guidance_raise": 0.2, "other": 0.1},
"sentiment_positive": 0.6,
"sentiment_negative": 0.1,
"sentiment_neutral": 0.3,
"magnitude": 0.05,
"surprise": 0.7,
"source_credibility": 0.8,
"novelty_score": 0.6,
"evidence_coverage": 0.9,
"company_sector": "Technology",
"company_industry": "Software",
"market_cap_bucket": "large",
"beta": 1.2,
"pre_event_volatility": 0.25,
"volume_regime": "normal",
"broad_market_regime": "bull",
"event_directness": "direct",
"document_type": "news",
"event_time": EVENT_TIME,
"feature_version": "1.0.0",
}
defaults.update(overrides)
return ImpactFeatureSet(**defaults)
def _make_price_series(
start: datetime, n_points: int = 100, base_price: float = 100.0, daily_return: float = 0.001
) -> list[tuple[datetime, float]]:
"""Generate a simple price series."""
series = []
price = base_price
for i in range(n_points):
ts = start + timedelta(hours=i)
series.append((ts, price))
price *= 1.0 + daily_return
return series
# ===========================================================================
# Task 36: Event-time feature snapshots
# ===========================================================================
class TestImpactFeatureSet:
"""Tests for ImpactFeatureSet model and snapshot persistence."""
def test_create_valid_feature_set(self):
fs = _make_feature_set()
assert fs.sentiment_positive == 0.6
assert fs.company_sector == "Technology"
assert fs.event_time == EVENT_TIME
def test_missing_numeric_as_nan(self):
fs = _make_feature_set(magnitude=float("nan"), surprise=float("nan"))
assert math.isnan(fs.magnitude)
assert math.isnan(fs.surprise)
def test_unknown_categorical_defaults(self):
fs = _make_feature_set(
company_sector="unknown",
volume_regime="unknown",
broad_market_regime="unknown",
)
assert fs.company_sector == "unknown"
assert fs.volume_regime == "unknown"
assert fs.broad_market_regime == "unknown"
def test_invalid_categorical_becomes_unknown(self):
fs = _make_feature_set(market_cap_bucket="supermassive")
assert fs.market_cap_bucket == "unknown"
def test_to_numeric_vector_returns_list(self):
fs = _make_feature_set()
vec = fs.to_numeric_vector()
assert isinstance(vec, list)
assert all(isinstance(v, float) for v in vec)
def test_persist_feature_snapshot_immutable(self):
clear_feature_snapshots()
fs = _make_feature_set()
pred_time = EVENT_TIME + timedelta(minutes=5)
snapshot_id = persist_feature_snapshot(fs, pred_time)
assert snapshot_id
assert get_feature_snapshot(snapshot_id) is not None
# Same content produces same ID (content-addressed)
snapshot_id_2 = persist_feature_snapshot(fs, pred_time)
assert snapshot_id == snapshot_id_2
def test_persist_rejects_future_prediction_time(self):
clear_feature_snapshots()
fs = _make_feature_set()
# prediction_time before event_time is invalid
pred_time = EVENT_TIME - timedelta(hours=1)
with pytest.raises(ValueError, match="cannot be before"):
persist_feature_snapshot(fs, pred_time)
def test_validate_no_future_leakage_clean(self):
fs = _make_feature_set()
timestamps = [EVENT_TIME - timedelta(hours=i) for i in range(1, 5)]
violations = validate_no_future_leakage(fs, timestamps)
assert violations == []
def test_validate_no_future_leakage_detects_post_event(self):
fs = _make_feature_set()
timestamps = [
EVENT_TIME - timedelta(hours=1),
EVENT_TIME + timedelta(hours=1), # LEAKAGE
]
violations = validate_no_future_leakage(fs, timestamps)
assert len(violations) == 1
assert "after" in violations[0]
# ===========================================================================
# Task 36.4: Property test — no post-event timestamps in features
# ===========================================================================
# Hypothesis strategy for valid feature sets
@st.composite
def feature_set_strategy(draw):
event_time = draw(
st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime(2025, 1, 1),
timezones=st.just(timezone.utc),
)
)
return _make_feature_set(event_time=event_time)
@st.composite
def pre_event_timestamps_strategy(draw, event_time: datetime):
"""Generate timestamps all before event_time."""
n = draw(st.integers(min_value=1, max_value=10))
timestamps = []
for _ in range(n):
offset_seconds = draw(st.integers(min_value=1, max_value=86400 * 30))
timestamps.append(event_time - timedelta(seconds=offset_seconds))
return timestamps
@given(
event_time=st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime(2025, 1, 1),
timezones=st.just(timezone.utc),
),
n_timestamps=st.integers(min_value=1, max_value=10),
)
@settings(max_examples=100)
def test_pbt_no_post_event_leakage(event_time, n_timestamps):
"""**Validates: Requirements 12.2, 12.10**
Property: When market_data_timestamps are all before event_time,
validate_no_future_leakage returns no violations.
"""
fs = _make_feature_set(event_time=event_time)
# All timestamps strictly before event_time
timestamps = [
event_time - timedelta(seconds=i + 1) for i in range(n_timestamps)
]
violations = validate_no_future_leakage(fs, timestamps)
assert violations == [], f"Expected no leakage but found: {violations}"
@given(
event_time=st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime(2024, 12, 31),
timezones=st.just(timezone.utc),
),
post_offset_seconds=st.integers(min_value=0, max_value=86400),
)
@settings(max_examples=100)
def test_pbt_detects_post_event_leakage(event_time, post_offset_seconds):
"""**Validates: Requirements 12.2, 12.10**
Property: When any market_data_timestamp is at or after event_time,
validate_no_future_leakage detects the violation.
"""
fs = _make_feature_set(event_time=event_time)
# Include one timestamp at or after event_time
timestamps = [event_time + timedelta(seconds=post_offset_seconds)]
violations = validate_no_future_leakage(fs, timestamps)
assert len(violations) >= 1
# ===========================================================================
# Task 37: Outcome labels
# ===========================================================================
class TestOutcomeLabels:
"""Tests for abnormal return computation and label generation."""
def test_compute_abnormal_return_basic(self):
"""Asset goes up 5%, benchmark goes up 2% → abnormal = 3%."""
event = EVENT_TIME
price_series = [
(event, 100.0),
(event + timedelta(days=1), 105.0),
]
bench_series = [
(event, 100.0),
(event + timedelta(days=1), 102.0),
]
result = compute_abnormal_return(
price_series, bench_series, event, timedelta(days=1)
)
assert abs(result - 0.03) < 1e-10
def test_compute_abnormal_return_negative(self):
"""Asset goes down 3%, benchmark goes up 1% → abnormal = -4%."""
event = EVENT_TIME
price_series = [
(event, 100.0),
(event + timedelta(days=1), 97.0),
]
bench_series = [
(event, 100.0),
(event + timedelta(days=1), 101.0),
]
result = compute_abnormal_return(
price_series, bench_series, event, timedelta(days=1)
)
assert abs(result - (-0.04)) < 1e-10
def test_compute_abnormal_return_empty_series_raises(self):
with pytest.raises(ValueError, match="empty"):
compute_abnormal_return([], [(EVENT_TIME, 100.0)], EVENT_TIME, timedelta(days=1))
def test_compute_abnormal_return_zero_price_raises(self):
event = EVENT_TIME
price_series = [(event, 0.0), (event + timedelta(days=1), 5.0)]
bench_series = [(event, 100.0), (event + timedelta(days=1), 101.0)]
with pytest.raises(ValueError, match="zero"):
compute_abnormal_return(price_series, bench_series, event, timedelta(days=1))
def test_compute_abnormal_volume(self):
event = EVENT_TIME
# Trailing: 20 days of volume=1000
volume_series = [
(event - timedelta(days=i), 1000.0) for i in range(1, 21)
]
# Event day: volume=3000 (3x normal)
volume_series.append((event, 3000.0))
volume_series.sort(key=lambda x: x[0])
result = compute_abnormal_volume(volume_series, event, timedelta(days=1))
assert result is not None
assert abs(result - 3.0) < 0.1
def test_compute_time_to_peak(self):
event = EVENT_TIME
# Price spikes 2 hours after event
price_series = [
(event, 100.0),
(event + timedelta(hours=1), 101.0),
(event + timedelta(hours=2), 105.0), # Peak
(event + timedelta(hours=3), 103.0),
(event + timedelta(hours=4), 102.0),
]
result = compute_time_to_peak(price_series, event, timedelta(hours=6))
assert result is not None
assert abs(result - 2.0) < 0.1
def test_generate_outcome_labels_all_horizons(self):
event = EVENT_TIME
# Generate enough price data for 90d horizon
price_series = _make_price_series(event - timedelta(hours=1), n_points=2200)
bench_series = _make_price_series(event - timedelta(hours=1), n_points=2200, daily_return=0.0005)
labels = generate_outcome_labels(
ticker="AAPL",
event_time=event,
price_series=price_series,
benchmark_series=bench_series,
)
assert labels.ticker == "AAPL"
assert labels.label_generator_version == LABEL_GENERATOR_VERSION
assert len(labels.labels) == 5 # All 5 horizons
def test_label_generator_version_tracked(self):
assert LABEL_GENERATOR_VERSION == "1.0.0"
# ===========================================================================
# Task 38: Deterministic impact baseline
# ===========================================================================
class TestDeterministicImpactBaseline:
"""Tests for the rule-based baseline model."""
def setup_method(self):
self.baseline = DeterministicImpactBaseline()
def test_predict_returns_impact_prediction(self):
fs = _make_feature_set()
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities is not None
assert prediction.expected_magnitude > 0
assert prediction.model_source.startswith("deterministic_baseline")
def test_earnings_beat_positive_direction(self):
"""Earnings beat should have predominantly positive direction."""
fs = _make_feature_set(
event_class_probabilities={"earnings_beat": 0.9, "other": 0.1},
sentiment_positive=0.7,
sentiment_negative=0.1,
sentiment_neutral=0.2,
)
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities["positive"] > prediction.direction_probabilities["negative"]
def test_earnings_miss_negative_direction(self):
"""Earnings miss should have predominantly negative direction."""
fs = _make_feature_set(
event_class_probabilities={"earnings_miss": 0.9, "other": 0.1},
sentiment_positive=0.1,
sentiment_negative=0.7,
sentiment_neutral=0.2,
)
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities["negative"] > prediction.direction_probabilities["positive"]
def test_guidance_raise_positive(self):
fs = _make_feature_set(
event_class_probabilities={"guidance_raise": 0.8, "other": 0.2},
sentiment_positive=0.6,
sentiment_negative=0.1,
sentiment_neutral=0.3,
)
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities["positive"] > 0.4
def test_guidance_cut_negative(self):
fs = _make_feature_set(
event_class_probabilities={"guidance_cut": 0.8, "other": 0.2},
sentiment_positive=0.1,
sentiment_negative=0.6,
sentiment_neutral=0.3,
)
prediction = self.baseline.predict(fs)
assert prediction.direction_probabilities["negative"] > 0.4
def test_ma_announcement_high_magnitude(self):
"""M&A has higher base magnitude than dividend changes."""
fs_ma = _make_feature_set(
event_class_probabilities={"ma_announcement": 0.9, "other": 0.1},
)
fs_div = _make_feature_set(
event_class_probabilities={"dividend_change": 0.9, "other": 0.1},
)
pred_ma = self.baseline.predict(fs_ma)
pred_div = self.baseline.predict(fs_div)
assert pred_ma.expected_magnitude > pred_div.expected_magnitude
def test_novelty_amplifies_magnitude(self):
"""Higher novelty should increase expected magnitude."""
fs_novel = _make_feature_set(novelty_score=0.9)
fs_stale = _make_feature_set(novelty_score=0.1)
pred_novel = self.baseline.predict(fs_novel)
pred_stale = self.baseline.predict(fs_stale)
assert pred_novel.expected_magnitude > pred_stale.expected_magnitude
def test_low_evidence_discounts_magnitude(self):
"""Low evidence coverage should reduce magnitude."""
fs_strong = _make_feature_set(evidence_coverage=0.95)
fs_weak = _make_feature_set(evidence_coverage=0.1)
pred_strong = self.baseline.predict(fs_strong)
pred_weak = self.baseline.predict(fs_weak)
assert pred_strong.expected_magnitude > pred_weak.expected_magnitude
def test_direct_event_shorter_horizon(self):
"""Direct events should have more weight on shorter horizons."""
fs_direct = _make_feature_set(event_directness="direct")
fs_spec = _make_feature_set(event_directness="speculative")
pred_direct = self.baseline.predict(fs_direct)
pred_spec = self.baseline.predict(fs_spec)
assert pred_direct.horizon_probabilities["intraday"] > pred_spec.horizon_probabilities["intraday"]
def test_unknown_event_high_uncertainty(self):
"""Unknown events should produce higher uncertainty."""
fs_known = _make_feature_set(
event_class_probabilities={"earnings_beat": 0.95, "other": 0.05},
)
fs_unknown = _make_feature_set(
event_class_probabilities={},
)
pred_known = self.baseline.predict(fs_known)
pred_unknown = self.baseline.predict(fs_unknown)
assert pred_unknown.uncertainty > pred_known.uncertainty
def test_all_event_classes_have_mappings(self):
"""Every event class in the lookup tables should produce valid output."""
for event_class in EVENT_CLASS_BASE_MAGNITUDE:
fs = _make_feature_set(
event_class_probabilities={event_class: 0.9, "other": 0.1},
)
prediction = self.baseline.predict(fs)
assert prediction.expected_magnitude > 0
total_dir = sum(prediction.direction_probabilities.values())
assert abs(total_dir - 1.0) < 0.01
total_hor = sum(prediction.horizon_probabilities.values())
assert abs(total_hor - 1.0) < 0.01
def test_direction_probabilities_sum_to_one(self):
"""Direction probs should always sum to approximately 1.0."""
for event_class in EVENT_CLASS_DIRECTION:
fs = _make_feature_set(
event_class_probabilities={event_class: 0.8, "other": 0.2},
)
prediction = self.baseline.predict(fs)
total = sum(prediction.direction_probabilities.values())
assert abs(total - 1.0) < 0.01, f"Failed for {event_class}: sum={total}"
def test_horizon_probabilities_sum_to_one(self):
"""Horizon probs should always sum to approximately 1.0."""
for event_class in EVENT_CLASS_DIRECTION:
fs = _make_feature_set(
event_class_probabilities={event_class: 0.8, "other": 0.2},
)
prediction = self.baseline.predict(fs)
total = sum(prediction.horizon_probabilities.values())
assert abs(total - 1.0) < 0.01, f"Failed for {event_class}: sum={total}"
def test_magnitude_bounded(self):
"""Magnitude should never exceed 2x base (conservative cap)."""
for event_class, base_mag in EVENT_CLASS_BASE_MAGNITUDE.items():
fs = _make_feature_set(
event_class_probabilities={event_class: 0.95, "other": 0.05},
novelty_score=1.0,
surprise=1.0,
evidence_coverage=1.0,
)
prediction = self.baseline.predict(fs)
assert prediction.expected_magnitude <= base_mag * 2.0 + 0.001
# ===========================================================================
# Task 39: Trained tabular impact model
# ===========================================================================
class TestTrainedImpactModel:
"""Tests for the trained tabular impact model."""
def setup_method(self):
clear_artifact_registry()
def _make_training_examples(self, n: int = 50) -> list[TrainingExample]:
"""Generate synthetic training examples."""
from services.intelligence_pipeline_v3.impact.labels import (
OutcomeLabel,
OutcomeLabelSet,
)
examples = []
base_time = datetime(2023, 1, 1, tzinfo=timezone.utc)
for i in range(n):
event_time = base_time + timedelta(days=i)
features = _make_feature_set(
event_time=event_time,
event_class_probabilities={"earnings_beat": 0.7, "other": 0.3},
sentiment_positive=0.5 + 0.3 * (i % 2),
sentiment_negative=0.2 - 0.1 * (i % 2),
sentiment_neutral=0.3 - 0.2 * (i % 2),
)
labels = OutcomeLabelSet(
event_time=event_time,
ticker="AAPL",
labels=[
OutcomeLabel(
horizon="1d",
signed_return=0.02 * (1 if i % 2 == 0 else -1),
absolute_return=0.02,
),
],
)
examples.append(TrainingExample(
features=features, labels=labels, ticker="AAPL", event_time=event_time
))
return examples
def test_train_produces_model_card(self):
examples = self._make_training_examples(50)
trainer = ImpactModelTrainer()
card = trainer.train(examples)
assert card.model_id
assert card.method == "gradient_boosted"
assert card.feature_version == "1.0.0"
assert card.total_training_samples > 0
def test_train_empty_raises(self):
trainer = ImpactModelTrainer()
with pytest.raises(ValueError, match="empty"):
trainer.train([])
def test_walk_forward_splits_temporal_ordering(self):
start = datetime(2023, 1, 1, tzinfo=timezone.utc)
end = datetime(2024, 1, 1, tzinfo=timezone.utc)
splits = create_walk_forward_splits(start, end, n_splits=3)
assert len(splits) == 3
for split in splits:
assert split.train_start <= split.train_end
assert split.train_end <= split.calibration_end
assert split.calibration_end <= split.validation_end
def test_trainer_predict_untrained_falls_to_baseline(self):
trainer = ImpactModelTrainer()
fs = _make_feature_set()
prediction = trainer.predict(fs)
assert "baseline" in prediction.model_source
def test_trainer_predict_after_training(self):
examples = self._make_training_examples(50)
trainer = ImpactModelTrainer()
trainer.train(examples)
assert trainer.is_trained
fs = _make_feature_set()
prediction = trainer.predict(fs)
assert prediction.direction_probabilities is not None
assert prediction.expected_magnitude >= 0
def test_register_and_retrieve_artifact(self):
examples = self._make_training_examples(30)
trainer = ImpactModelTrainer()
card = trainer.train(examples)
model_id = register_model_artifact(card)
assert get_model_artifact(model_id) is not None
def test_no_approved_model_initially(self):
assert get_approved_model() is None
def test_model_card_has_segment_metrics(self):
examples = self._make_training_examples(50)
trainer = ImpactModelTrainer()
card = trainer.train(examples)
# Should have metrics by event and sector
assert isinstance(card.metrics_by_event, list)
assert isinstance(card.metrics_by_sector, list)
# ===========================================================================
# Task 40: Impact output integration
# ===========================================================================
class TestImpactIntegration:
"""Tests for impact output integration and legacy compatibility."""
def setup_method(self):
clear_comparison_metrics()
def test_impact_prediction_output_model(self):
pred = ImpactPredictionOutput(
direction_probs=DirectionProbabilities(positive=0.6, negative=0.2, neutral=0.2),
expected_magnitude=0.03,
signed_magnitude=0.02,
horizon_probs=HorizonProbabilities(
intraday=0.3, one_day=0.3, seven_day=0.2, thirty_day=0.1, ninety_day=0.1
),
uncertainty=0.3,
model_source="deterministic_baseline_v1.0.0",
)
assert pred.direction_probs.positive == 0.6
assert pred.expected_magnitude == 0.03
def test_map_to_legacy_impact_score(self):
pred = ImpactPredictionOutput(
direction_probs=DirectionProbabilities(positive=0.7, negative=0.1, neutral=0.2),
expected_magnitude=0.04,
signed_magnitude=0.03,
horizon_probs=HorizonProbabilities(
intraday=0.1, one_day=0.4, seven_day=0.3, thirty_day=0.15, ninety_day=0.05
),
uncertainty=0.3,
model_source="test",
)
legacy = map_to_legacy_impact(pred)
assert legacy.impact_score == 0.03
assert legacy.impact_horizon == "1d"
def test_map_to_legacy_clamps_score(self):
pred = ImpactPredictionOutput(
direction_probs=DirectionProbabilities(positive=0.9, negative=0.0, neutral=0.1),
expected_magnitude=2.0,
signed_magnitude=1.5, # Exceeds 1.0
horizon_probs=HorizonProbabilities(intraday=0.5, one_day=0.3),
uncertainty=0.2,
model_source="test",
)
legacy = map_to_legacy_impact(pred)
assert legacy.impact_score == 1.0 # Clamped
def test_filter_generative_scores_v3_mode(self):
config = ImpactPipelineConfig(v3_mode_enabled=True)
signal = {
"impact_score": 0.5,
"impact_horizon": "7d",
"novelty_score": 0.8,
"confidence": 0.7,
"sentiment": "positive",
"ticker": "AAPL",
}
filtered = filter_generative_scores(signal, config)
assert "impact_score" not in filtered
assert "impact_horizon" not in filtered
assert "novelty_score" not in filtered
assert "confidence" not in filtered
# Non-generative fields preserved
assert filtered["sentiment"] == "positive"
assert filtered["ticker"] == "AAPL"
def test_filter_generative_scores_disabled(self):
config = ImpactPipelineConfig(v3_mode_enabled=False)
signal = {"impact_score": 0.5, "ticker": "AAPL"}
filtered = filter_generative_scores(signal, config)
assert filtered == signal # No filtering when disabled
def test_comparison_metrics_disabled_by_default(self):
metric = ComparisonMetric(
ticker="AAPL",
event_time=EVENT_TIME,
prediction_source="baseline",
predicted_direction="positive",
predicted_magnitude=0.03,
predicted_horizon="1d",
)
# Default config has comparison disabled
record_comparison_metric(metric)
assert get_comparison_metrics() == []
def test_comparison_metrics_when_enabled(self):
import os
os.environ["IMPACT_COMPARISON_METRICS"] = "true"
try:
metric = ComparisonMetric(
ticker="AAPL",
event_time=EVENT_TIME,
prediction_source="baseline",
predicted_direction="positive",
predicted_magnitude=0.03,
predicted_horizon="1d",
)
record_comparison_metric(metric)
metrics = get_comparison_metrics()
assert len(metrics) == 1
assert metrics[0].ticker == "AAPL"
finally:
os.environ.pop("IMPACT_COMPARISON_METRICS", None)
clear_comparison_metrics()
@@ -0,0 +1,514 @@
"""Tests for retrieval-based novelty and duplicate detection.
Validates:
- Exact fingerprint consistency
- SimHash near-duplicate detection
- Embedding backend returns correct dimensions
- Cosine similarity bounds
- Index search returns sorted results
- Novelty formula returns [0, 1] range
- Duplicate document gets low novelty
- Novel document gets high novelty
"""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.novelty.embeddings import (
MockEmbeddingBackend,
SentenceTransformerBackend,
cosine_similarity,
)
from services.intelligence_pipeline_v3.novelty.fingerprints import (
compute_exact_fingerprint,
compute_simhash,
hamming_distance,
is_near_duplicate,
)
from services.intelligence_pipeline_v3.novelty.index import NoveltyIndex
from services.intelligence_pipeline_v3.novelty.scorer import NoveltyScorer
# --- Fingerprint tests ---
class TestExactFingerprint:
"""Test exact fingerprint consistency."""
def test_same_text_same_fingerprint(self) -> None:
"""Identical text always produces the same fingerprint."""
text = "Apple reports record quarterly revenue of $94.8 billion"
fp1 = compute_exact_fingerprint(text)
fp2 = compute_exact_fingerprint(text)
assert fp1 == fp2
def test_normalized_whitespace(self) -> None:
"""Different whitespace patterns produce the same fingerprint."""
text1 = "Apple reports record quarterly revenue"
text2 = "Apple reports record quarterly revenue"
text3 = "Apple\treports\nrecord\tquarterly revenue"
assert compute_exact_fingerprint(text1) == compute_exact_fingerprint(text2)
assert compute_exact_fingerprint(text1) == compute_exact_fingerprint(text3)
def test_case_insensitive(self) -> None:
"""Case differences produce the same fingerprint."""
text1 = "Apple Reports Record Quarterly Revenue"
text2 = "apple reports record quarterly revenue"
assert compute_exact_fingerprint(text1) == compute_exact_fingerprint(text2)
def test_different_text_different_fingerprint(self) -> None:
"""Meaningfully different text produces different fingerprints."""
fp1 = compute_exact_fingerprint("Apple reports record revenue")
fp2 = compute_exact_fingerprint("Google reports declining revenue")
assert fp1 != fp2
def test_fingerprint_is_hex_sha256(self) -> None:
"""Fingerprint is a valid 64-char hex SHA-256 digest."""
fp = compute_exact_fingerprint("test content")
assert len(fp) == 64
assert all(c in "0123456789abcdef" for c in fp)
def test_empty_text(self) -> None:
"""Empty text produces a valid fingerprint."""
fp = compute_exact_fingerprint("")
assert len(fp) == 64
# Empty and whitespace-only should match after normalization
assert fp == compute_exact_fingerprint(" ")
class TestSimhashNearDuplicate:
"""Test SimHash near-duplicate detection."""
def test_identical_text_zero_distance(self) -> None:
"""Identical text has hamming distance 0."""
text = "Apple reports record quarterly revenue of $94.8 billion"
sh1 = compute_simhash(text)
sh2 = compute_simhash(text)
assert hamming_distance(sh1, sh2) == 0
def test_similar_text_lower_distance_than_unrelated(self) -> None:
"""Text with minor edits has lower distance than completely unrelated text."""
text1 = "Apple reports record quarterly revenue of $94.8 billion dollars"
text2 = "Apple reports record quarterly revenue of $94.8 billion usd"
text3 = "The weather in Tokyo is sunny with temperatures around 25 degrees"
sh1 = compute_simhash(text1)
sh2 = compute_simhash(text2)
sh3 = compute_simhash(text3)
# Similar texts should have lower distance than unrelated texts
assert hamming_distance(sh1, sh2) < hamming_distance(sh1, sh3)
def test_different_text_high_distance(self) -> None:
"""Completely different text should have higher distance."""
text1 = "Apple reports record quarterly revenue of $94.8 billion"
text2 = "The weather in Tokyo is sunny with temperatures around 25 degrees"
sh1 = compute_simhash(text1)
sh2 = compute_simhash(text2)
# Very different content should produce measurable distance
assert hamming_distance(sh1, sh2) > 5
def test_is_near_duplicate_true(self) -> None:
"""Near-duplicate detection returns True for identical content."""
text = "The Federal Reserve raised interest rates by 25 basis points today"
sh1 = compute_simhash(text)
sh2 = compute_simhash(text)
# Identical text has distance 0, always a near-duplicate
assert is_near_duplicate(sh1, sh2) is True
assert hamming_distance(sh1, sh2) == 0
def test_is_near_duplicate_false_for_unrelated(self) -> None:
"""Near-duplicate detection returns False for unrelated documents."""
text1 = "Apple reports record quarterly revenue of $94.8 billion"
text2 = "The weather in Tokyo is sunny with temperatures around 25 degrees"
sh1 = compute_simhash(text1)
sh2 = compute_simhash(text2)
# Very different content should not be near-duplicate at threshold 3
# (depends on content but unlikely to collide)
distance = hamming_distance(sh1, sh2)
assert distance > 3 or not is_near_duplicate(sh1, sh2, threshold=2)
def test_hamming_distance_bounds(self) -> None:
"""Hamming distance is always between 0 and 64 for 64-bit hashes."""
sh1 = compute_simhash("test text one")
sh2 = compute_simhash("different content entirely here")
dist = hamming_distance(sh1, sh2)
assert 0 <= dist <= 64
def test_hamming_distance_symmetric(self) -> None:
"""Hamming distance is symmetric: d(a,b) == d(b,a)."""
sh1 = compute_simhash("first document")
sh2 = compute_simhash("second document")
assert hamming_distance(sh1, sh2) == hamming_distance(sh2, sh1)
def test_empty_text_simhash(self) -> None:
"""Empty text produces a simhash of 0."""
assert compute_simhash("") == 0
assert compute_simhash(" ") == 0
def test_custom_threshold(self) -> None:
"""Custom threshold adjusts near-duplicate sensitivity."""
sh1 = 0b1111111111111111111111111111111111111111111111111111111111111111
sh2 = 0b1111111111111111111111111111111111111111111111111111111111111110
# Distance is 1
assert is_near_duplicate(sh1, sh2, threshold=1) is True
assert is_near_duplicate(sh1, sh2, threshold=0) is False
# --- Embedding backend tests ---
class TestEmbeddingBackend:
"""Test embedding backend returns correct dimensions."""
def test_mock_backend_correct_dimension(self) -> None:
"""MockEmbeddingBackend produces vectors of specified dimension."""
backend = MockEmbeddingBackend(dimension=384)
texts = ["Test sentence one", "Test sentence two"]
embeddings = backend.embed(texts)
assert len(embeddings) == 2
assert all(len(e) == 384 for e in embeddings)
def test_mock_backend_custom_dimension(self) -> None:
"""MockEmbeddingBackend respects custom dimension."""
backend = MockEmbeddingBackend(dimension=128)
embeddings = backend.embed(["hello world"])
assert len(embeddings[0]) == 128
def test_mock_backend_deterministic(self) -> None:
"""Same text always produces the same embedding."""
backend = MockEmbeddingBackend(dimension=384)
text = "Apple reports revenue"
e1 = backend.embed([text])
e2 = backend.embed([text])
assert e1 == e2
def test_mock_backend_different_texts_different_embeddings(self) -> None:
"""Different texts produce different embeddings."""
backend = MockEmbeddingBackend(dimension=384)
embeddings = backend.embed(["Apple revenue", "Google revenue"])
assert embeddings[0] != embeddings[1]
def test_mock_backend_unit_normalized(self) -> None:
"""MockEmbeddingBackend produces approximately unit-normalized vectors."""
import math
backend = MockEmbeddingBackend(dimension=384)
embeddings = backend.embed(["test text"])
norm = math.sqrt(sum(x * x for x in embeddings[0]))
assert abs(norm - 1.0) < 1e-6
def test_sentence_transformer_dimension_property(self) -> None:
"""SentenceTransformerBackend declares 384 dimensions."""
backend = SentenceTransformerBackend()
assert backend.dimension == 384
def test_empty_text_embedding(self) -> None:
"""Empty string can be embedded without error."""
backend = MockEmbeddingBackend(dimension=384)
embeddings = backend.embed([""])
assert len(embeddings) == 1
assert len(embeddings[0]) == 384
# --- Cosine similarity tests ---
class TestCosineSimilarity:
"""Test cosine similarity bounds."""
def test_identical_vectors(self) -> None:
"""Identical vectors have similarity 1.0."""
v = [1.0, 2.0, 3.0]
assert abs(cosine_similarity(v, v) - 1.0) < 1e-9
def test_opposite_vectors(self) -> None:
"""Opposite vectors have similarity -1.0."""
v1 = [1.0, 0.0, 0.0]
v2 = [-1.0, 0.0, 0.0]
assert abs(cosine_similarity(v1, v2) - (-1.0)) < 1e-9
def test_orthogonal_vectors(self) -> None:
"""Orthogonal vectors have similarity 0.0."""
v1 = [1.0, 0.0, 0.0]
v2 = [0.0, 1.0, 0.0]
assert abs(cosine_similarity(v1, v2)) < 1e-9
def test_similarity_in_bounds(self) -> None:
"""Cosine similarity is always in [-1, 1]."""
backend = MockEmbeddingBackend(dimension=64)
texts = ["apple", "banana", "cherry", "date"]
embeddings = backend.embed(texts)
for i in range(len(embeddings)):
for j in range(len(embeddings)):
sim = cosine_similarity(embeddings[i], embeddings[j])
assert -1.0 - 1e-9 <= sim <= 1.0 + 1e-9
def test_zero_vector(self) -> None:
"""Zero vector returns similarity 0.0."""
v1 = [0.0, 0.0, 0.0]
v2 = [1.0, 2.0, 3.0]
assert cosine_similarity(v1, v2) == 0.0
def test_dimension_mismatch_raises(self) -> None:
"""Mismatched dimensions raise ValueError."""
v1 = [1.0, 2.0]
v2 = [1.0, 2.0, 3.0]
with pytest.raises(ValueError, match="same dimension"):
cosine_similarity(v1, v2)
# --- Index search tests ---
class TestNoveltyIndex:
"""Test index search returns sorted results."""
def test_search_returns_sorted_by_similarity(self) -> None:
"""Search results are sorted descending by similarity."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
texts = ["apple stock", "banana fruit", "cherry pie", "apple revenue"]
embeddings = backend.embed(texts)
for i, (text, emb) in enumerate(zip(texts, embeddings)):
index.add(f"doc_{i}", emb, {"text": text})
# Query with something similar to "apple stock"
query = embeddings[0]
results = index.search(query, k=4)
# Results should be sorted descending
for i in range(len(results) - 1):
assert results[i].similarity_score >= results[i + 1].similarity_score
def test_search_top_match_is_self(self) -> None:
"""Searching with an indexed embedding returns itself as top match."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
emb = backend.embed(["test document"])[0]
index.add("doc_1", emb)
results = index.search(emb, k=1)
assert len(results) == 1
assert results[0].doc_id == "doc_1"
assert results[0].similarity_score > 0.99
def test_search_respects_k(self) -> None:
"""Search returns at most k results."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
for i in range(10):
emb = backend.embed([f"document {i}"])[0]
index.add(f"doc_{i}", emb)
query = backend.embed(["document 0"])[0]
results = index.search(query, k=3)
assert len(results) == 3
def test_search_empty_index(self) -> None:
"""Searching an empty index returns empty results."""
index = NoveltyIndex()
results = index.search([0.1] * 64, k=5)
assert results == []
def test_add_and_update(self) -> None:
"""Adding with an existing doc_id updates the embedding."""
index = NoveltyIndex()
index.add("doc_1", [1.0, 0.0, 0.0])
index.add("doc_1", [0.0, 1.0, 0.0])
assert len(index) == 1
results = index.search([0.0, 1.0, 0.0], k=1)
assert results[0].doc_id == "doc_1"
assert results[0].similarity_score > 0.99
def test_remove(self) -> None:
"""Removing a document excludes it from search."""
index = NoveltyIndex()
index.add("doc_1", [1.0, 0.0, 0.0])
index.add("doc_2", [0.0, 1.0, 0.0])
assert len(index) == 2
index.remove("doc_1")
assert len(index) == 1
results = index.search([1.0, 0.0, 0.0], k=5)
assert all(r.doc_id != "doc_1" for r in results)
def test_similarity_scores_clamped(self) -> None:
"""Similarity scores are clamped to [0, 1]."""
index = NoveltyIndex()
index.add("doc_1", [1.0, 0.0, 0.0])
index.add("doc_2", [-1.0, 0.0, 0.0])
results = index.search([1.0, 0.0, 0.0], k=2)
for r in results:
assert 0.0 <= r.similarity_score <= 1.0
# --- Novelty formula tests ---
class TestNoveltyScorer:
"""Test novelty formula returns [0, 1] range."""
def test_novelty_in_range(self) -> None:
"""All novelty scores are in [0, 1]."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
# Add some documents to the index
for i in range(5):
emb = backend.embed([f"existing document number {i}"])[0]
index.add(f"existing_{i}", emb)
# Score a new document
doc_emb = backend.embed(["new document about technology"])[0]
event_emb = backend.embed(["tech earnings beat"])[0]
scorer = NoveltyScorer(k=3)
result = scorer.compute_novelty(doc_emb, event_emb, index)
assert 0.0 <= result.document_novelty <= 1.0
assert 0.0 <= result.event_novelty <= 1.0
assert 0.0 <= result.combined_novelty <= 1.0
def test_duplicate_gets_low_novelty(self) -> None:
"""An exact duplicate document gets low novelty scores."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
# Index a document
text = "Apple reports record quarterly revenue of $94.8 billion"
emb = backend.embed([text])[0]
index.add("original_doc", emb)
# Score the same document
scorer = NoveltyScorer(k=5)
result = scorer.compute_novelty(emb, emb, index)
# Should have very low novelty (embedding matches itself)
assert result.document_novelty < 0.1
assert result.event_novelty < 0.1
assert result.combined_novelty < 0.1
def test_novel_document_gets_high_novelty(self) -> None:
"""A document unlike anything in the index gets high novelty."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
# Index documents about one topic
for i in range(5):
emb = backend.embed([f"weather forecast for city {i} rain expected"])[0]
index.add(f"weather_{i}", emb)
# Score a completely different topic
doc_emb = backend.embed(["semiconductor shortage impacts automotive production"])[0]
event_emb = backend.embed(["chip supply constraint"])[0]
scorer = NoveltyScorer(k=5)
result = scorer.compute_novelty(doc_emb, event_emb, index)
# Should have high novelty
assert result.document_novelty > 0.5
assert result.event_novelty > 0.5
assert result.combined_novelty > 0.5
def test_exact_duplicate_flag_forces_zero_novelty(self) -> None:
"""When is_exact_duplicate=True, novelty is 0."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
emb = backend.embed(["test"])[0]
index.add("doc_1", emb)
scorer = NoveltyScorer(k=5)
result = scorer.compute_novelty(emb, emb, index, is_exact_duplicate=True)
assert result.document_novelty == 0.0
assert result.event_novelty == 0.0
assert result.combined_novelty == 0.0
assert result.is_exact_duplicate is True
def test_near_duplicate_flag_caps_novelty(self) -> None:
"""Near-duplicate flag caps document novelty at 0.2."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
# Index something mildly related
index.add("doc_1", backend.embed(["somewhat related content"])[0])
doc_emb = backend.embed(["quite different content here"])[0]
event_emb = backend.embed(["different event"])[0]
scorer = NoveltyScorer(k=5)
result = scorer.compute_novelty(doc_emb, event_emb, index, is_near_duplicate=True)
assert result.document_novelty <= 0.2
assert result.is_near_duplicate is True
def test_empty_index_full_novelty(self) -> None:
"""Empty index (no history) returns full novelty."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
doc_emb = backend.embed(["brand new content"])[0]
event_emb = backend.embed(["new event"])[0]
scorer = NoveltyScorer(k=5)
result = scorer.compute_novelty(doc_emb, event_emb, index)
assert result.document_novelty == 1.0
assert result.event_novelty == 1.0
assert result.combined_novelty == 1.0
def test_formula_version_tracked(self) -> None:
"""Result includes the formula version used."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
emb = backend.embed(["test"])[0]
scorer = NoveltyScorer(k=5, formula_version="v1.0")
result = scorer.compute_novelty(emb, emb, index)
assert result.formula_version == "v1.0"
def test_nearest_matches_included(self) -> None:
"""Result includes nearest matches for explainability."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
for i in range(3):
emb = backend.embed([f"document {i}"])[0]
index.add(f"doc_{i}", emb)
query_emb = backend.embed(["document 0"])[0]
scorer = NoveltyScorer(k=5)
result = scorer.compute_novelty(query_emb, query_emb, index)
assert len(result.nearest_matches) > 0
# Matches should be sorted by similarity descending
for i in range(len(result.nearest_matches) - 1):
assert (
result.nearest_matches[i].similarity_score
>= result.nearest_matches[i + 1].similarity_score
)
def test_combined_novelty_is_minimum(self) -> None:
"""Combined novelty is the minimum of document and event novelty."""
backend = MockEmbeddingBackend(dimension=64)
index = NoveltyIndex()
# Add a document similar to our test doc
doc_emb = backend.embed(["known document"])[0]
index.add("existing", doc_emb)
# Query with something similar to doc but different event
event_emb = backend.embed(["completely new event topic"])[0]
scorer = NoveltyScorer(k=5)
result = scorer.compute_novelty(doc_emb, event_emb, index)
assert result.combined_novelty <= result.document_novelty
assert result.combined_novelty <= result.event_novelty
assert result.combined_novelty == min(result.document_novelty, result.event_novelty)
@@ -0,0 +1,572 @@
"""Tests for NuExtract 1.5 Smol benchmark and promotion logic.
Tests:
- Adapter interface (test mode extraction)
- Benchmark comparison logic
- Promotion gate pass/fail
- Per-document-type reporting
Requirement: 6.6
"""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.nuextract.adapter import (
NUEXTRACT_MODEL_VERSION,
NuExtractAdapter,
)
from services.intelligence_pipeline_v3.nuextract.benchmark import (
GLiNERResult,
GoldDocument,
NuExtractBenchmark,
)
from services.intelligence_pipeline_v3.nuextract.models import (
BenchmarkReport,
IncrementalValueReport,
NuExtractResult,
PromotionGate,
)
from services.intelligence_pipeline_v3.nuextract.promotion import PromotionEvaluator
# --- Fixtures ---
@pytest.fixture
def adapter() -> NuExtractAdapter:
"""Create a test-mode NuExtract adapter."""
return NuExtractAdapter(test_mode=True)
@pytest.fixture
def benchmark() -> NuExtractBenchmark:
"""Create a benchmark instance with test-mode adapter."""
return NuExtractBenchmark(
adapter=NuExtractAdapter(test_mode=True),
gate=PromotionGate(
min_f1_improvement=0.05,
max_latency_ms=5000.0,
max_memory_mb=2048.0,
min_sample_count=3,
),
)
@pytest.fixture
def strict_gate() -> PromotionGate:
"""Strict promotion gate that's hard to pass."""
return PromotionGate(
min_f1_improvement=0.20,
max_latency_ms=100.0,
max_memory_mb=512.0,
min_sample_count=100,
)
@pytest.fixture
def lenient_gate() -> PromotionGate:
"""Lenient promotion gate that's easy to pass."""
return PromotionGate(
min_f1_improvement=0.01,
max_latency_ms=10000.0,
max_memory_mb=4096.0,
min_sample_count=1,
)
def _make_filing_doc(revenue: str = "4.2 billion") -> GoldDocument:
"""Create a sample filing document."""
return GoldDocument(
text=(
f"Revenue: {revenue}\n"
"Net Income: 1.3 billion\n"
"Earnings Per Share: 2.45\n"
"The company reported strong growth driven by cloud services."
),
document_type="filing",
gold_fields={
"revenue": revenue,
"net_income": "1.3 billion",
"earnings_per_share": "2.45",
},
schema={
"properties": {
"revenue": {"type": "string"},
"net_income": {"type": "string"},
"earnings_per_share": {"type": "string"},
}
},
)
def _make_transcript_doc() -> GoldDocument:
"""Create a sample transcript document."""
return GoldDocument(
text=(
"CEO: We expect guidance of 5.0 to 5.2 billion for next quarter.\n"
"CFO: Operating Margin: improved to 28 percent year over year.\n"
"Analyst: What about the competitive landscape?\n"
"CEO: We see strong demand across all segments."
),
document_type="transcript",
gold_fields={
"guidance": "5.0 to 5.2 billion",
"operating_margin": "28 percent",
},
schema={
"properties": {
"guidance": {"type": "string"},
"operating_margin": {"type": "string"},
}
},
)
def _make_article_doc() -> GoldDocument:
"""Create a sample article document."""
return GoldDocument(
text=(
"Apple announced a new product line today. "
"The stock price: rose 3.5% in after-hours trading. "
"Analysts expect Revenue: 95 billion for the quarter."
),
document_type="article",
gold_fields={
"stock_price": "rose 3.5%",
"revenue": "95 billion",
},
schema={
"properties": {
"stock_price": {"type": "string"},
"revenue": {"type": "string"},
}
},
)
# --- Test Adapter Interface ---
class TestNuExtractAdapter:
"""Test the NuExtract adapter interface."""
@pytest.mark.asyncio
async def test_extract_returns_result(self, adapter: NuExtractAdapter) -> None:
"""Adapter returns a valid NuExtractResult."""
result = await adapter.extract(
text="Revenue: 4.2 billion\nNet Income: 1.3 billion",
schema={"properties": {"revenue": {"type": "string"}}},
)
assert isinstance(result, NuExtractResult)
assert result.model_version == NUEXTRACT_MODEL_VERSION
assert result.error is None
@pytest.mark.asyncio
async def test_extract_captures_latency(self, adapter: NuExtractAdapter) -> None:
"""Extraction records latency in milliseconds."""
result = await adapter.extract(
text="Revenue: 10 million",
schema={"properties": {"revenue": {"type": "string"}}},
)
assert result.latency_ms >= 0.0
@pytest.mark.asyncio
async def test_extract_finds_matching_fields(self, adapter: NuExtractAdapter) -> None:
"""Adapter extracts fields that match schema keys in text."""
result = await adapter.extract(
text="Revenue: 4.2 billion\nEPS: 2.45",
schema={
"properties": {
"revenue": {"type": "string"},
"eps": {"type": "string"},
}
},
)
field_names = [f.name for f in result.fields]
assert "revenue" in field_names
@pytest.mark.asyncio
async def test_extract_sets_document_type(self, adapter: NuExtractAdapter) -> None:
"""Document type is preserved in result."""
result = await adapter.extract(
text="Some filing content",
schema={"properties": {"field": {"type": "string"}}},
document_type="filing",
)
assert result.document_type == "filing"
@pytest.mark.asyncio
async def test_extract_stores_schema_used(self, adapter: NuExtractAdapter) -> None:
"""Schema is stored in result for lineage."""
schema = {"properties": {"revenue": {"type": "string"}}}
result = await adapter.extract(text="Revenue: 100", schema=schema)
assert result.schema_used == schema
@pytest.mark.asyncio
async def test_extract_handles_empty_text(self, adapter: NuExtractAdapter) -> None:
"""Adapter handles empty text gracefully."""
result = await adapter.extract(
text="",
schema={"properties": {"field": {"type": "string"}}},
)
assert isinstance(result, NuExtractResult)
assert result.error is None
@pytest.mark.asyncio
async def test_extract_hierarchical_schema(self, adapter: NuExtractAdapter) -> None:
"""Adapter handles nested/hierarchical schemas."""
result = await adapter.extract(
text="Revenue: 4.2 billion\nSegment growth: 15%",
schema={
"properties": {
"financials": {
"properties": {
"revenue": {"type": "string"},
"segment_growth": {"type": "string"},
}
}
}
},
)
assert isinstance(result, NuExtractResult)
def test_model_version_pinned(self, adapter: NuExtractAdapter) -> None:
"""Model version is pinned and accessible."""
assert adapter.model_version == NUEXTRACT_MODEL_VERSION
assert "NuExtract" in adapter.model_name
def test_test_mode_does_not_load_model(self, adapter: NuExtractAdapter) -> None:
"""Test mode doesn't attempt to load the real model."""
assert not adapter.is_loaded
def test_unload_is_safe_in_test_mode(self, adapter: NuExtractAdapter) -> None:
"""Unload is a no-op in test mode."""
adapter.unload()
assert not adapter.is_loaded
# --- Test Benchmark Comparison Logic ---
class TestBenchmarkComparison:
"""Test the benchmark comparison between NuExtract and GLiNER2."""
@pytest.mark.asyncio
async def test_benchmark_produces_report(self, benchmark: NuExtractBenchmark) -> None:
"""Benchmark returns a complete BenchmarkReport."""
docs = [_make_filing_doc(), _make_filing_doc("5.1 billion")]
gliner_results = [
GLiNERResult(fields={"revenue": "4.2 billion", "net_income": "1.3 billion"}),
GLiNERResult(fields={"revenue": "5.1 billion", "net_income": "1.3 billion"}),
]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
assert isinstance(report, BenchmarkReport)
assert report.total_documents == 2
assert len(report.reports) == 1 # One doc type: filing
@pytest.mark.asyncio
async def test_benchmark_groups_by_document_type(self, benchmark: NuExtractBenchmark) -> None:
"""Benchmark reports separately for each document type."""
docs = [_make_filing_doc(), _make_transcript_doc(), _make_article_doc()]
gliner_results = [
GLiNERResult(fields={"revenue": "4.2 billion"}),
GLiNERResult(fields={"guidance": "5.0 to 5.2 billion"}),
GLiNERResult(fields={"stock_price": "rose 3.5%"}),
]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
doc_types = {r.document_type for r in report.reports}
assert "filing" in doc_types
assert "transcript" in doc_types
assert "article" in doc_types
@pytest.mark.asyncio
async def test_benchmark_computes_f1_delta(self, benchmark: NuExtractBenchmark) -> None:
"""Delta is computed as nuextract_f1 - gliner_f1."""
docs = [_make_filing_doc(), _make_filing_doc(), _make_filing_doc()]
gliner_results = [
GLiNERResult(fields={"revenue": "4.2 billion"}),
GLiNERResult(fields={"revenue": "4.2 billion"}),
GLiNERResult(fields={"revenue": "4.2 billion"}),
]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
for r in report.reports:
assert r.delta == pytest.approx(r.nuextract_f1 - r.gliner_f1, abs=1e-6)
@pytest.mark.asyncio
async def test_benchmark_rejects_mismatched_lengths(self, benchmark: NuExtractBenchmark) -> None:
"""Benchmark raises when document and result counts differ."""
docs = [_make_filing_doc(), _make_filing_doc()]
gliner_results = [GLiNERResult(fields={"revenue": "4.2 billion"})]
with pytest.raises(ValueError, match="must match"):
await benchmark.evaluate_against_gliner(docs, gliner_results)
@pytest.mark.asyncio
async def test_benchmark_tracks_latency(self, benchmark: NuExtractBenchmark) -> None:
"""Benchmark records latency metrics per type."""
docs = [_make_filing_doc(), _make_filing_doc(), _make_filing_doc()]
gliner_results = [
GLiNERResult(fields={"revenue": "4.2 billion"}, latency_ms=50.0),
GLiNERResult(fields={"revenue": "4.2 billion"}, latency_ms=60.0),
GLiNERResult(fields={"revenue": "4.2 billion"}, latency_ms=55.0),
]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
filing_report = report.reports[0]
assert filing_report.gliner_latency_ms > 0.0
assert filing_report.nuextract_latency_ms >= 0.0
@pytest.mark.asyncio
async def test_benchmark_overall_metrics(self, benchmark: NuExtractBenchmark) -> None:
"""Overall metrics are weighted averages across types."""
docs = [_make_filing_doc(), _make_filing_doc(), _make_filing_doc()]
gliner_results = [
GLiNERResult(fields={"revenue": "4.2 billion", "net_income": "1.3 billion", "earnings_per_share": "2.45"}),
GLiNERResult(fields={"revenue": "4.2 billion", "net_income": "1.3 billion", "earnings_per_share": "2.45"}),
GLiNERResult(fields={"revenue": "4.2 billion", "net_income": "1.3 billion", "earnings_per_share": "2.45"}),
]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
assert report.overall_gliner_f1 >= 0.0
assert report.overall_nuextract_f1 >= 0.0
assert report.overall_delta == pytest.approx(
report.overall_nuextract_f1 - report.overall_gliner_f1, abs=1e-6
)
# --- Test Promotion Gate ---
class TestPromotionGate:
"""Test the promotion gate pass/fail logic."""
def test_promotion_passes_when_all_gates_met(self, lenient_gate: PromotionGate) -> None:
"""Promotion passes when all thresholds are met."""
evaluator = PromotionEvaluator(lenient_gate)
report = IncrementalValueReport(
document_type="filing",
gliner_f1=0.80,
nuextract_f1=0.85,
delta=0.05,
nuextract_latency_ms=200.0,
nuextract_memory_mb=500.0,
sample_count=100,
)
assert evaluator.evaluate(report) is True
def test_promotion_fails_insufficient_f1(self, strict_gate: PromotionGate) -> None:
"""Promotion fails when F1 improvement is below threshold."""
evaluator = PromotionEvaluator(strict_gate)
report = IncrementalValueReport(
document_type="filing",
gliner_f1=0.80,
nuextract_f1=0.82,
delta=0.02, # Below 0.20 threshold
nuextract_latency_ms=50.0,
nuextract_memory_mb=200.0,
sample_count=100,
)
assert evaluator.evaluate(report) is False
def test_promotion_fails_high_latency(self) -> None:
"""Promotion fails when latency exceeds the gate."""
gate = PromotionGate(
min_f1_improvement=0.01,
max_latency_ms=100.0,
max_memory_mb=4096.0,
min_sample_count=1,
)
evaluator = PromotionEvaluator(gate)
report = IncrementalValueReport(
document_type="transcript",
gliner_f1=0.70,
nuextract_f1=0.80,
delta=0.10,
nuextract_latency_ms=500.0, # Exceeds 100ms gate
nuextract_memory_mb=200.0,
sample_count=50,
)
assert evaluator.evaluate(report) is False
def test_promotion_fails_high_memory(self) -> None:
"""Promotion fails when memory exceeds the gate."""
gate = PromotionGate(
min_f1_improvement=0.01,
max_latency_ms=10000.0,
max_memory_mb=512.0,
min_sample_count=1,
)
evaluator = PromotionEvaluator(gate)
report = IncrementalValueReport(
document_type="article",
gliner_f1=0.70,
nuextract_f1=0.85,
delta=0.15,
nuextract_latency_ms=200.0,
nuextract_memory_mb=1024.0, # Exceeds 512MB gate
sample_count=50,
)
assert evaluator.evaluate(report) is False
def test_promotion_fails_insufficient_samples(self) -> None:
"""Promotion fails when sample count is below minimum."""
gate = PromotionGate(
min_f1_improvement=0.01,
max_latency_ms=10000.0,
max_memory_mb=4096.0,
min_sample_count=100,
)
evaluator = PromotionEvaluator(gate)
report = IncrementalValueReport(
document_type="filing",
gliner_f1=0.70,
nuextract_f1=0.90,
delta=0.20,
nuextract_latency_ms=200.0,
nuextract_memory_mb=500.0,
sample_count=10, # Below 100 minimum
)
assert evaluator.evaluate(report) is False
def test_rejection_reasons_reported(self) -> None:
"""Evaluator provides specific rejection reasons."""
gate = PromotionGate(
min_f1_improvement=0.10,
max_latency_ms=100.0,
max_memory_mb=512.0,
min_sample_count=50,
)
evaluator = PromotionEvaluator(gate)
report = IncrementalValueReport(
document_type="filing",
gliner_f1=0.80,
nuextract_f1=0.82,
delta=0.02, # Below threshold
nuextract_latency_ms=500.0, # Above threshold
nuextract_memory_mb=1024.0, # Above threshold
sample_count=10, # Below minimum
)
reasons = evaluator.get_rejection_reasons(report)
assert len(reasons) == 4
assert any("F1" in r for r in reasons)
assert any("Latency" in r for r in reasons)
assert any("Memory" in r for r in reasons)
assert any("samples" in r.lower() for r in reasons)
def test_no_rejection_reasons_when_passing(self, lenient_gate: PromotionGate) -> None:
"""No rejection reasons when all gates pass."""
evaluator = PromotionEvaluator(lenient_gate)
report = IncrementalValueReport(
document_type="filing",
gliner_f1=0.70,
nuextract_f1=0.80,
delta=0.10,
nuextract_latency_ms=200.0,
nuextract_memory_mb=500.0,
sample_count=100,
)
reasons = evaluator.get_rejection_reasons(report)
assert reasons == []
# --- Test Per-Document-Type Reporting ---
class TestPerDocumentTypeReporting:
"""Test that benchmark produces correct per-type reports."""
@pytest.mark.asyncio
async def test_promoted_types_listed(self) -> None:
"""Promoted types appear in the benchmark report."""
gate = PromotionGate(
min_f1_improvement=0.0, # Accept any improvement
max_latency_ms=10000.0,
max_memory_mb=4096.0,
min_sample_count=1,
)
benchmark = NuExtractBenchmark(
adapter=NuExtractAdapter(test_mode=True),
gate=gate,
)
# Filing doc where NuExtract should find matches (schema keys appear in text)
docs = [_make_filing_doc()]
# GLiNER returns empty to ensure NuExtract has higher F1
gliner_results = [GLiNERResult(fields={})]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
# With empty GLiNER results, NuExtract should score higher
for r in report.reports:
if r.nuextract_f1 > r.gliner_f1:
assert r.document_type in report.promoted_types
@pytest.mark.asyncio
async def test_non_promoted_types_excluded(self) -> None:
"""Types that don't pass gates are not in promoted list."""
gate = PromotionGate(
min_f1_improvement=0.99, # Nearly impossible to pass
max_latency_ms=10000.0,
max_memory_mb=4096.0,
min_sample_count=1,
)
benchmark = NuExtractBenchmark(
adapter=NuExtractAdapter(test_mode=True),
gate=gate,
)
docs = [_make_filing_doc()]
gliner_results = [
GLiNERResult(
fields={"revenue": "4.2 billion", "net_income": "1.3 billion", "earnings_per_share": "2.45"}
)
]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
assert report.promoted_types == []
@pytest.mark.asyncio
async def test_sample_count_per_type(self) -> None:
"""Sample count reflects the number of documents per type."""
benchmark = NuExtractBenchmark(
adapter=NuExtractAdapter(test_mode=True),
gate=PromotionGate(min_sample_count=1),
)
docs = [
_make_filing_doc(),
_make_filing_doc("5.0 billion"),
_make_transcript_doc(),
]
gliner_results = [
GLiNERResult(fields={"revenue": "4.2 billion"}),
GLiNERResult(fields={"revenue": "5.0 billion"}),
GLiNERResult(fields={"guidance": "5.0 to 5.2 billion"}),
]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
type_counts = {r.document_type: r.sample_count for r in report.reports}
assert type_counts["filing"] == 2
assert type_counts["transcript"] == 1
@pytest.mark.asyncio
async def test_f1_scores_bounded(self) -> None:
"""F1 scores are always between 0 and 1."""
benchmark = NuExtractBenchmark(
adapter=NuExtractAdapter(test_mode=True),
gate=PromotionGate(min_sample_count=1),
)
docs = [_make_filing_doc(), _make_transcript_doc(), _make_article_doc()]
gliner_results = [
GLiNERResult(fields={"revenue": "wrong value"}),
GLiNERResult(fields={"guidance": "wrong"}),
GLiNERResult(fields={}),
]
report = await benchmark.evaluate_against_gliner(docs, gliner_results)
for r in report.reports:
assert 0.0 <= r.gliner_f1 <= 1.0
assert 0.0 <= r.nuextract_f1 <= 1.0
@@ -0,0 +1,350 @@
"""Tests for the v3 pipeline orchestrator — state machine, queues, leases, flags."""
from __future__ import annotations
from datetime import timedelta
from uuid import uuid4
import pytest
from services.intelligence_pipeline_v3.orchestrator.feature_flags import (
FeatureFlags,
PipelineVersion,
)
from services.intelligence_pipeline_v3.orchestrator.leases import (
LeaseExpiredError,
LeaseManager,
)
from services.intelligence_pipeline_v3.orchestrator.queues import (
QueueMessage,
QueueName,
QueueRouter,
)
from services.intelligence_pipeline_v3.orchestrator.state import (
PipelineState,
PipelineStateMachine,
StageState,
)
# ---------------------------------------------------------------------------
# State Machine Tests
# ---------------------------------------------------------------------------
class TestPipelineStateMachine:
"""Task 41.1: Explicit stage state transitions and idempotency keys."""
def test_initial_state_is_pending(self):
sm = PipelineStateMachine(document_id="doc-001")
assert sm.state == PipelineState.PENDING
def test_valid_transition_pending_to_segmenting(self):
sm = PipelineStateMachine(document_id="doc-001")
t = sm.transition_pipeline(PipelineState.SEGMENTING, "start processing")
assert sm.state == PipelineState.SEGMENTING
assert t.from_state == PipelineState.PENDING
assert t.to_state == PipelineState.SEGMENTING
assert t.idempotency_key != ""
def test_invalid_transition_raises(self):
sm = PipelineStateMachine(document_id="doc-001")
with pytest.raises(ValueError, match="Invalid pipeline transition"):
sm.transition_pipeline(PipelineState.COMPLETED)
def test_full_happy_path_transitions(self):
sm = PipelineStateMachine(document_id="doc-001")
states = [
PipelineState.SEGMENTING,
PipelineState.EXTRACTING,
PipelineState.RESOLVING,
PipelineState.VERIFYING,
PipelineState.ROUTING,
PipelineState.IMPACT,
PipelineState.PERSISTING,
PipelineState.COMPLETED,
]
for state in states:
sm.transition_pipeline(state)
assert sm.state == PipelineState.COMPLETED
assert len(sm.history) == len(states)
def test_routing_can_go_to_adjudication(self):
sm = PipelineStateMachine(document_id="doc-001")
for s in [
PipelineState.SEGMENTING,
PipelineState.EXTRACTING,
PipelineState.RESOLVING,
PipelineState.VERIFYING,
PipelineState.ROUTING,
]:
sm.transition_pipeline(s)
sm.transition_pipeline(PipelineState.ADJUDICATING)
assert sm.state == PipelineState.ADJUDICATING
def test_stage_state_transitions(self):
sm = PipelineStateMachine(document_id="doc-001")
sm.transition_stage("extraction", StageState.LEASED)
assert sm.stage_states["extraction"] == StageState.LEASED
sm.transition_stage("extraction", StageState.RUNNING)
assert sm.stage_states["extraction"] == StageState.RUNNING
sm.transition_stage("extraction", StageState.SUCCEEDED)
assert sm.stage_states["extraction"] == StageState.SUCCEEDED
def test_stage_invalid_transition_raises(self):
sm = PipelineStateMachine(document_id="doc-001")
sm.transition_stage("extraction", StageState.LEASED)
with pytest.raises(ValueError, match="Invalid stage transition"):
sm.transition_stage("extraction", StageState.SUCCEEDED)
def test_idempotency_key_is_deterministic(self):
sm1 = PipelineStateMachine(document_id="doc-001")
sm2 = PipelineStateMachine(document_id="doc-001")
t1 = sm1.transition_pipeline(PipelineState.SEGMENTING)
t2 = sm2.transition_pipeline(PipelineState.SEGMENTING)
assert t1.idempotency_key == t2.idempotency_key
def test_can_retry_tracks_attempts(self):
sm = PipelineStateMachine(document_id="doc-001", max_retries=2)
sm.transition_stage("extraction", StageState.LEASED)
sm.transition_stage("extraction", StageState.RUNNING)
sm.transition_stage("extraction", StageState.RETRYING)
assert sm.can_retry("extraction")
sm.transition_stage("extraction", StageState.QUEUED)
sm.transition_stage("extraction", StageState.LEASED)
sm.transition_stage("extraction", StageState.RUNNING)
sm.transition_stage("extraction", StageState.RETRYING)
assert not sm.can_retry("extraction")
def test_dead_letter_after_max_retries(self):
sm = PipelineStateMachine(document_id="doc-001", max_retries=1)
sm.transition_stage("extraction", StageState.LEASED)
sm.transition_stage("extraction", StageState.RUNNING)
sm.transition_stage("extraction", StageState.RETRYING)
sm.transition_pipeline(PipelineState.SEGMENTING)
sm.transition_pipeline(PipelineState.FAILED)
assert sm.should_dead_letter()
# ---------------------------------------------------------------------------
# Queue Tests
# ---------------------------------------------------------------------------
class TestQueueRouter:
"""Task 41.2: Fast-path, adjudication, persistence, and review queues."""
def test_all_queue_names_defined(self):
assert QueueName.INCOMING
assert QueueName.FAST_PATH
assert QueueName.ADJUDICATION
assert QueueName.PERSISTENCE
assert QueueName.REVIEW
assert QueueName.DEAD_LETTER
def test_enqueue_and_dequeue(self):
router = QueueRouter()
msg = QueueMessage.create(
queue=QueueName.FAST_PATH,
run_id=uuid4(),
document_id="doc-001",
)
assert router.enqueue(msg)
assert router.depth(QueueName.FAST_PATH) == 1
dequeued = router.dequeue(QueueName.FAST_PATH)
assert dequeued is not None
assert dequeued.document_id == "doc-001"
def test_backpressure_rejects_at_max_depth(self):
router = QueueRouter(max_depth=2)
run_id = uuid4()
for i in range(2):
msg = QueueMessage.create(
queue=QueueName.FAST_PATH, run_id=run_id, document_id=f"doc-{i}"
)
assert router.enqueue(msg)
# Third should be rejected
msg = QueueMessage.create(
queue=QueueName.FAST_PATH, run_id=run_id, document_id="doc-3"
)
assert not router.enqueue(msg)
def test_idempotency_rejects_duplicate_keys(self):
router = QueueRouter()
run_id = uuid4()
msg = QueueMessage.create(
queue=QueueName.FAST_PATH,
run_id=run_id,
document_id="doc-001",
idempotency_key="key-123",
)
assert router.enqueue(msg)
router.dequeue(QueueName.FAST_PATH)
# Second enqueue with same key should be rejected
msg2 = QueueMessage.create(
queue=QueueName.FAST_PATH,
run_id=run_id,
document_id="doc-001",
idempotency_key="key-123",
)
assert not router.enqueue(msg2)
def test_move_to_dead_letter(self):
router = QueueRouter()
msg = QueueMessage.create(
queue=QueueName.FAST_PATH, run_id=uuid4(), document_id="doc-001"
)
router.enqueue(msg)
original = router.dequeue(QueueName.FAST_PATH)
assert original is not None
dlq_msg = router.move_to_dead_letter(original)
assert dlq_msg.queue == QueueName.DEAD_LETTER
assert router.depth(QueueName.DEAD_LETTER) == 1
def test_dequeue_empty_returns_none(self):
router = QueueRouter()
assert router.dequeue(QueueName.REVIEW) is None
def test_is_saturated(self):
router = QueueRouter(max_depth=5)
run_id = uuid4()
for i in range(5):
msg = QueueMessage.create(
queue=QueueName.ADJUDICATION, run_id=run_id, document_id=f"doc-{i}"
)
router.enqueue(msg)
assert router.is_saturated(QueueName.ADJUDICATION)
# ---------------------------------------------------------------------------
# Lease Tests
# ---------------------------------------------------------------------------
class TestLeaseManager:
"""Task 41.3: Leases, retry policies, dead-letter handling."""
def test_acquire_lease(self):
mgr = LeaseManager()
run_id = uuid4()
lease = mgr.acquire(run_id, "extraction", "worker-1")
assert lease is not None
assert lease.is_active
assert not lease.is_expired
def test_cannot_double_acquire(self):
mgr = LeaseManager()
run_id = uuid4()
lease1 = mgr.acquire(run_id, "extraction", "worker-1")
lease2 = mgr.acquire(run_id, "extraction", "worker-2")
assert lease1 is not None
assert lease2 is None
def test_release_allows_reacquisition(self):
mgr = LeaseManager()
run_id = uuid4()
lease = mgr.acquire(run_id, "extraction", "worker-1")
assert lease is not None
mgr.release(lease)
lease2 = mgr.acquire(run_id, "extraction", "worker-2")
assert lease2 is not None
def test_expired_lease_allows_reacquisition(self):
mgr = LeaseManager(default_ttl=timedelta(seconds=-1))
run_id = uuid4()
lease = mgr.acquire(run_id, "extraction", "worker-1")
assert lease is not None
assert lease.is_expired
# Another worker can acquire
lease2 = mgr.acquire(run_id, "extraction", "worker-2")
assert lease2 is not None
def test_renew_extends_lease(self):
mgr = LeaseManager(default_ttl=timedelta(seconds=60))
run_id = uuid4()
lease = mgr.acquire(run_id, "extraction", "worker-1")
assert lease is not None
original_expiry = lease.expires_at
mgr.renew(lease, timedelta(seconds=120))
assert lease.expires_at > original_expiry
assert lease.renewed_count == 1
def test_renew_expired_raises(self):
mgr = LeaseManager(default_ttl=timedelta(seconds=-1))
run_id = uuid4()
lease = mgr.acquire(run_id, "extraction", "worker-1")
assert lease is not None
with pytest.raises(LeaseExpiredError):
mgr.renew(lease)
def test_active_count(self):
mgr = LeaseManager()
run_id = uuid4()
mgr.acquire(run_id, "extraction", "worker-1")
mgr.acquire(run_id, "sentiment", "worker-2")
assert mgr.active_count() == 2
# ---------------------------------------------------------------------------
# Feature Flag Tests
# ---------------------------------------------------------------------------
class TestFeatureFlags:
"""Task 41.4: Independent v2/v3 routing behind feature flags."""
def test_default_routes_to_v2(self):
flags = FeatureFlags()
assert flags.resolve("doc-001") == PipelineVersion.V2
def test_v3_enabled_routes_to_v3(self):
flags = FeatureFlags(v3_enabled=True, default_version=PipelineVersion.V3)
assert flags.resolve("doc-001") == PipelineVersion.V3
def test_shadow_mode_returns_shadow(self):
flags = FeatureFlags(shadow_enabled=True)
assert flags.resolve("doc-001") == PipelineVersion.SHADOW
def test_percentage_routing_is_deterministic(self):
flags = FeatureFlags(v3_enabled=True, v3_percentage=50)
result1 = flags.resolve("doc-001")
result2 = flags.resolve("doc-001")
assert result1 == result2
def test_agent_override_takes_precedence(self):
flags = FeatureFlags(v3_enabled=True, v3_percentage=0)
flags.set_agent_override("agent-1", PipelineVersion.V3)
assert (
flags.resolve("doc-001", agent_id="agent-1") == PipelineVersion.V3
)
# Different agent uses default
result = flags.resolve("doc-001", agent_id="agent-2")
# Not v3 since percentage is 0 and no override for agent-2
assert result in (PipelineVersion.V2, PipelineVersion.V3)
def test_document_type_override(self):
flags = FeatureFlags(v3_enabled=True)
flags.document_type_overrides["filing"] = PipelineVersion.V3
assert (
flags.resolve("doc-001", document_type="filing")
== PipelineVersion.V3
)
def test_excluded_document_type(self):
flags = FeatureFlags(v3_enabled=True, v3_percentage=100)
flags.excluded_document_types.add("transcript")
assert (
flags.resolve("doc-001", document_type="transcript")
== PipelineVersion.V2
)
def test_is_v3_active(self):
flags = FeatureFlags()
assert not flags.is_v3_active()
flags.v3_enabled = True
assert flags.is_v3_active()
def test_to_dict_serialization(self):
flags = FeatureFlags(v3_enabled=True, v3_percentage=25)
d = flags.to_dict()
assert d["v3_enabled"] is True
assert d["v3_percentage"] == 25
@@ -0,0 +1,171 @@
"""Tests for bounded application parallelism — Task 42."""
from __future__ import annotations
import asyncio
import pytest
from services.intelligence_pipeline_v3.orchestrator.parallelism import (
AdjudicatorSemaphore,
AsyncWorkerPool,
DocumentPriority,
LoadSheddingAction,
MicroBatcher,
WorkerPoolConfig,
)
# ---------------------------------------------------------------------------
# Worker Pool Tests
# ---------------------------------------------------------------------------
class TestAsyncWorkerPool:
"""Task 42.1: Configurable async workers replacing sequential loop."""
@pytest.mark.asyncio
async def test_submit_processes_work(self):
pool = AsyncWorkerPool(WorkerPoolConfig(max_workers=2))
results = []
async def work(value: int):
results.append(value)
result = await pool.submit(work, 42)
assert result is None # No shedding
await asyncio.sleep(0.05)
assert 42 in results
@pytest.mark.asyncio
async def test_available_slots(self):
pool = AsyncWorkerPool(WorkerPoolConfig(max_workers=4))
assert pool.available_slots == 4
@pytest.mark.asyncio
async def test_load_shedding_rejects_low_priority(self):
config = WorkerPoolConfig(
max_workers=1, queue_max_depth=1, shed_threshold=0.5
)
pool = AsyncWorkerPool(config)
pool._stats.queued_items = 1 # Simulate full queue
assert pool.should_shed_load()
async def noop():
pass
result = await pool.submit(
noop, priority=DocumentPriority.LOW
)
assert result == LoadSheddingAction.REJECT
@pytest.mark.asyncio
async def test_safety_critical_never_shed(self):
config = WorkerPoolConfig(
max_workers=1, queue_max_depth=1, shed_threshold=0.5
)
pool = AsyncWorkerPool(config)
pool._stats.queued_items = 1 # Simulate full queue
async def noop():
pass
result = await pool.submit(
noop, priority=DocumentPriority.SAFETY_CRITICAL
)
# Safety-critical is never rejected
assert result is None
@pytest.mark.asyncio
async def test_stats_track_processed(self):
pool = AsyncWorkerPool(WorkerPoolConfig(max_workers=2))
async def work():
pass
await pool.submit(work)
await asyncio.sleep(0.05)
assert pool.stats.processed_total >= 1
@pytest.mark.asyncio
async def test_shutdown(self):
pool = AsyncWorkerPool()
await pool.start()
assert pool.is_running
await pool.shutdown(timeout=1.0)
assert not pool.is_running
# ---------------------------------------------------------------------------
# Adjudicator Semaphore Tests
# ---------------------------------------------------------------------------
class TestAdjudicatorSemaphore:
"""Task 42.3: GPU-safe concurrency semaphore."""
@pytest.mark.asyncio
async def test_acquire_and_release(self):
sem = AdjudicatorSemaphore(max_concurrent=2)
assert await sem.acquire()
assert sem.active_count == 1
sem.release()
assert sem.active_count == 0
@pytest.mark.asyncio
async def test_backpressure_when_queue_full(self):
sem = AdjudicatorSemaphore(max_concurrent=2, max_queued=0)
# Queue is immediately "full"
result = await sem.acquire()
assert result is False
@pytest.mark.asyncio
async def test_utilization(self):
sem = AdjudicatorSemaphore(max_concurrent=4)
await sem.acquire()
await sem.acquire()
assert sem.utilization == 0.5
@pytest.mark.asyncio
async def test_is_backpressured(self):
sem = AdjudicatorSemaphore(max_concurrent=2, max_queued=1)
sem._queued = 1
assert sem.is_backpressured
# ---------------------------------------------------------------------------
# Micro-Batcher Tests
# ---------------------------------------------------------------------------
class TestMicroBatcher:
"""Task 42.2: Specialist micro-batching."""
def test_batch_fills_at_size(self):
batcher = MicroBatcher(batch_size=3)
assert batcher.add("a") is None
assert batcher.add("b") is None
batch = batcher.add("c")
assert batch == ["a", "b", "c"]
assert batcher.is_empty
def test_flush_returns_partial(self):
batcher = MicroBatcher(batch_size=10)
batcher.add("x")
batcher.add("y")
batch = batcher.flush()
assert batch == ["x", "y"]
assert batcher.is_empty
def test_pending_count(self):
batcher = MicroBatcher(batch_size=5)
batcher.add(1)
batcher.add(2)
assert batcher.pending_count == 2
def test_total_batches_tracked(self):
batcher = MicroBatcher(batch_size=2)
batcher.add(1)
batcher.add(2) # First batch
batcher.add(3)
batcher.add(4) # Second batch
assert batcher.total_batches == 2
@@ -0,0 +1,583 @@
"""Unit tests and property tests for the deterministic financial parser.
Tests cover:
- 23.1: Parse tickers, currencies, money, percentages, basis points, ranges, EPS, revenue, dates, fiscal periods
- 23.2: Store literal and normalized representations
- 23.3: Link each candidate to exact offsets
- 23.4: Property tests for numeric formatting and unit conversions
"""
from __future__ import annotations
import math
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from services.intelligence_pipeline_v3.parsing import (
CandidateType,
FinancialParser,
normalize_value,
)
from services.intelligence_pipeline_v3.parsing.normalizer import (
normalize_basis_points,
normalize_money,
normalize_percentage,
normalize_range,
)
@pytest.fixture
def parser() -> FinancialParser:
return FinancialParser()
# ---------------------------------------------------------------------------
# 23.1 — Parse tickers, currencies, money, percentages, basis points,
# ranges, EPS, revenue, dates, and fiscal periods
# ---------------------------------------------------------------------------
class TestTickerParsing:
"""Test ticker symbol detection."""
def test_dollar_ticker(self, parser: FinancialParser) -> None:
"""Detect $AAPL style tickers."""
results = parser.parse("Shares of $AAPL rose 3% today.")
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
assert len(tickers) == 1
assert tickers[0].literal_value == "$AAPL"
def test_multiple_tickers(self, parser: FinancialParser) -> None:
"""Detect multiple tickers in one text."""
results = parser.parse("$AAPL and $MSFT both reported earnings.")
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
assert len(tickers) == 2
literals = {t.literal_value for t in tickers}
assert "$AAPL" in literals
assert "$MSFT" in literals
def test_ticker_not_confused_with_currency(self, parser: FinancialParser) -> None:
"""$AAPL (letters) is a ticker, not currency."""
results = parser.parse("$AAPL hit $200.")
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
assert any(t.literal_value == "$AAPL" for t in tickers)
assert any(c.literal_value == "$200" for c in currencies)
class TestCurrencyParsing:
"""Test simple currency detection."""
def test_usd_amount(self, parser: FinancialParser) -> None:
results = parser.parse("The stock traded at $123.45 today.")
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
assert len(currencies) >= 1
assert any(c.literal_value == "$123.45" for c in currencies)
def test_euro_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Trading at €99 in Frankfurt.")
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
assert len(currencies) == 1
assert currencies[0].literal_value == "€99"
assert currencies[0].unit == "EUR"
def test_gbp_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Shares are £1,234.56 in London.")
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
assert len(currencies) == 1
assert currencies[0].normalized_value == 1234.56
assert currencies[0].unit == "GBP"
class TestMoneyParsing:
"""Test money amounts with multipliers."""
def test_billion_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Apple reported $94.9 billion in revenue.")
# Should get a revenue match (more specific than money)
revenue = [r for r in results if r.candidate_type == CandidateType.REVENUE]
assert len(revenue) >= 1
def test_million_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Operating costs were $45 million last quarter.")
money = [r for r in results if r.candidate_type == CandidateType.MONEY]
assert len(money) >= 1
assert any(m.normalized_value == 45_000_000.0 for m in money)
class TestPercentageParsing:
"""Test percentage detection."""
def test_simple_percentage(self, parser: FinancialParser) -> None:
results = parser.parse("The stock rose 4% today.")
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
assert len(pcts) == 1
assert pcts[0].normalized_value == 4.0
assert pcts[0].unit == "%"
def test_negative_percentage(self, parser: FinancialParser) -> None:
results = parser.parse("Revenue declined -2.5% year-over-year.")
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
assert len(pcts) == 1
assert pcts[0].normalized_value == -2.5
def test_percent_word(self, parser: FinancialParser) -> None:
results = parser.parse("Margins expanded 1.2 percent this quarter.")
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
assert len(pcts) == 1
assert pcts[0].normalized_value == 1.2
class TestBasisPointsParsing:
"""Test basis points detection."""
def test_basis_points_full(self, parser: FinancialParser) -> None:
results = parser.parse("The Fed raised rates by 25 basis points.")
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
assert len(bps) == 1
assert bps[0].normalized_value == pytest.approx(0.25)
assert bps[0].unit == "bps"
def test_bps_abbreviation(self, parser: FinancialParser) -> None:
results = parser.parse("Spreads widened 50bps today.")
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
assert len(bps) == 1
assert bps[0].normalized_value == pytest.approx(0.50)
def test_bps_with_space(self, parser: FinancialParser) -> None:
results = parser.parse("Credit spreads tightened 100 bps.")
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
assert len(bps) == 1
assert bps[0].normalized_value == pytest.approx(1.0)
class TestRangeParsing:
"""Test range detection."""
def test_dollar_range_dash(self, parser: FinancialParser) -> None:
results = parser.parse("Guidance was $10-$12 per share.")
ranges = [r for r in results if r.candidate_type == CandidateType.RANGE]
assert len(ranges) == 1
assert ranges[0].normalized_value == pytest.approx(11.0)
def test_dollar_range_to(self, parser: FinancialParser) -> None:
results = parser.parse("Expected range $1.50 to $2.00.")
ranges = [r for r in results if r.candidate_type == CandidateType.RANGE]
assert len(ranges) == 1
assert ranges[0].normalized_value == pytest.approx(1.75)
class TestEPSParsing:
"""Test EPS detection."""
def test_eps_per_share(self, parser: FinancialParser) -> None:
results = parser.parse("The company earned $1.52 per share.")
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
assert len(eps) == 1
assert eps[0].normalized_value == pytest.approx(1.52)
assert eps[0].unit == "USD"
def test_eps_prefix(self, parser: FinancialParser) -> None:
results = parser.parse("EPS of $2.18 beat expectations.")
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
assert len(eps) == 1
assert eps[0].normalized_value == pytest.approx(2.18)
class TestRevenueParsing:
"""Test revenue figure detection."""
def test_revenue_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Apple reported $94.9 billion in revenue.")
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
assert len(rev) == 1
assert rev[0].normalized_value == pytest.approx(94_900_000_000.0)
assert rev[0].unit == "USD"
def test_revenue_prefix(self, parser: FinancialParser) -> None:
results = parser.parse("Revenue reached $50 billion this year.")
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
assert len(rev) == 1
assert rev[0].normalized_value == pytest.approx(50_000_000_000.0)
class TestDateParsing:
"""Test date detection."""
def test_named_month_date(self, parser: FinancialParser) -> None:
results = parser.parse("The report was filed on January 15, 2024.")
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
assert len(dates) == 1
assert "January 15" in dates[0].literal_value
def test_abbreviated_month(self, parser: FinancialParser) -> None:
results = parser.parse("Earnings released on Oct 28, 2024.")
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
assert len(dates) == 1
assert "Oct 28" in dates[0].literal_value
def test_iso_date(self, parser: FinancialParser) -> None:
results = parser.parse("Published on 2024-01-15.")
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
assert len(dates) == 1
assert dates[0].literal_value == "2024-01-15"
class TestFiscalPeriodParsing:
"""Test fiscal period detection."""
def test_quarter_with_year(self, parser: FinancialParser) -> None:
results = parser.parse("Results for Q1 2024 were strong.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].period is not None
assert periods[0].period.period_type == "quarter"
assert periods[0].period.period_value == "Q1"
assert periods[0].period.year == 2024
def test_fiscal_year(self, parser: FinancialParser) -> None:
results = parser.parse("FY2025 outlook is positive.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].period is not None
assert periods[0].period.period_type == "fiscal_year"
assert periods[0].period.year == 2025
def test_half_year(self, parser: FinancialParser) -> None:
results = parser.parse("H1 2024 revenue grew 15%.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].period is not None
assert periods[0].period.period_type == "half"
assert periods[0].period.period_value == "H1"
def test_short_year(self, parser: FinancialParser) -> None:
results = parser.parse("Q4'24 results beat estimates.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].period is not None
assert periods[0].period.year == 2024
# ---------------------------------------------------------------------------
# 23.2 — Store literal and normalized representations
# ---------------------------------------------------------------------------
class TestLiteralAndNormalized:
"""Test that both literal text and normalized numeric values are stored."""
def test_money_stores_both(self, parser: FinancialParser) -> None:
text = "$94.9 billion in revenue"
results = parser.parse(text)
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
assert len(rev) == 1
# Literal preserved exactly
assert rev[0].literal_value == "$94.9 billion in revenue"
# Normalized to numeric
assert rev[0].normalized_value == pytest.approx(94_900_000_000.0)
def test_percentage_stores_both(self, parser: FinancialParser) -> None:
text = "Growth was 4% this quarter."
results = parser.parse(text)
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
assert len(pcts) == 1
assert pcts[0].literal_value == "4%"
assert pcts[0].normalized_value == 4.0
def test_basis_points_stores_both(self, parser: FinancialParser) -> None:
text = "Rates increased 25 basis points."
results = parser.parse(text)
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
assert len(bps) == 1
assert "25 basis points" in bps[0].literal_value
assert bps[0].normalized_value == pytest.approx(0.25)
def test_eps_stores_both(self, parser: FinancialParser) -> None:
text = "EPS was $1.52 per share."
results = parser.parse(text)
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
assert len(eps) == 1
assert "$1.52 per share" in eps[0].literal_value
assert eps[0].normalized_value == pytest.approx(1.52)
def test_ticker_has_no_normalized_value(self, parser: FinancialParser) -> None:
"""Tickers don't have numeric values."""
results = parser.parse("$AAPL is up today.")
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
assert len(tickers) == 1
assert tickers[0].normalized_value is None
def test_date_has_no_normalized_value(self, parser: FinancialParser) -> None:
"""Dates don't have numeric values (they have period annotations)."""
results = parser.parse("Q1 2024 results are strong.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].normalized_value is None
assert periods[0].period is not None
# ---------------------------------------------------------------------------
# 23.3 — Link each candidate to exact offsets
# ---------------------------------------------------------------------------
class TestExactOffsets:
"""Test that each candidate has correct start_char and end_char."""
def test_offset_matches_source(self, parser: FinancialParser) -> None:
"""literal_value must equal text[start_char:end_char]."""
text = "Apple earned $1.52 per share in Q1 2024."
results = parser.parse(text)
for candidate in results:
assert text[candidate.start_char:candidate.end_char] == candidate.literal_value, (
f"Offset mismatch for {candidate.candidate_type}: "
f"expected '{candidate.literal_value}' but got "
f"'{text[candidate.start_char:candidate.end_char]}'"
)
def test_offsets_for_multiple_candidates(self, parser: FinancialParser) -> None:
"""Multiple candidates all have valid offsets."""
text = "$AAPL reported $94.9 billion in revenue, up 15% year-over-year."
results = parser.parse(text)
assert len(results) >= 3 # ticker, revenue, percentage
for candidate in results:
assert text[candidate.start_char:candidate.end_char] == candidate.literal_value
def test_offsets_no_overlap(self, parser: FinancialParser) -> None:
"""Candidates should not have overlapping offsets."""
text = "$AAPL rose 3% after reporting $94.9 billion in revenue and EPS of $2.18."
results = parser.parse(text)
for i in range(len(results)):
for j in range(i + 1, len(results)):
a = results[i]
b = results[j]
# No overlap: one must end before the other starts
assert a.end_char <= b.start_char or b.end_char <= a.start_char, (
f"Overlap between {a.candidate_type}[{a.start_char}:{a.end_char}] "
f"and {b.candidate_type}[{b.start_char}:{b.end_char}]"
)
def test_offsets_within_bounds(self, parser: FinancialParser) -> None:
"""All offsets must be within the text bounds."""
text = "The stock price was $45.67 in Q3 2024."
results = parser.parse(text)
for candidate in results:
assert candidate.start_char >= 0
assert candidate.end_char <= len(text)
assert candidate.start_char < candidate.end_char
def test_empty_text_no_candidates(self, parser: FinancialParser) -> None:
"""Empty text produces no candidates."""
assert parser.parse("") == []
assert parser.parse(" \n\t ") == []
# ---------------------------------------------------------------------------
# 23.4 — Property tests for numeric formatting and unit conversions
# ---------------------------------------------------------------------------
class TestPropertyBasedFinancialParser:
"""Property-based tests for financial parsing.
**Validates: Requirements 5.1, 5.7, 5.8**
"""
@given(
amount=st.floats(min_value=0.01, max_value=999.99, allow_nan=False, allow_infinity=False),
)
@settings(max_examples=100)
def test_currency_strings_parse_to_expected_value(self, amount: float) -> None:
"""Property: Generated currency strings parse to their expected value.
**Validates: Requirements 5.1, 5.7**
"""
# Round to 2 decimal places for realistic currency
amount = round(amount, 2)
text = f"The price was ${amount:.2f} per unit."
parser = FinancialParser()
results = parser.parse(text)
# Should find at least one currency or EPS match
numeric_candidates = [
r for r in results
if r.candidate_type in (CandidateType.CURRENCY, CandidateType.EPS)
and r.normalized_value is not None
]
assert len(numeric_candidates) >= 1
assert any(
abs(c.normalized_value - amount) < 0.01
for c in numeric_candidates
), f"No candidate matched expected value {amount}"
@given(
pct=st.floats(min_value=-99.9, max_value=99.9, allow_nan=False, allow_infinity=False),
)
@settings(max_examples=100)
def test_percentage_strings_parse_to_expected_value(self, pct: float) -> None:
"""Property: Generated percentage strings parse to their expected value.
**Validates: Requirements 5.1, 5.7**
"""
pct = round(pct, 1)
if pct == 0.0:
pct = 1.0 # Avoid edge case with sign
sign = "+" if pct > 0 else ""
text = f"Revenue changed {sign}{pct}% this quarter."
parser = FinancialParser()
results = parser.parse(text)
pct_candidates = [
r for r in results
if r.candidate_type == CandidateType.PERCENTAGE
and r.normalized_value is not None
]
assert len(pct_candidates) >= 1
assert any(
abs(c.normalized_value - pct) < 0.1
for c in pct_candidates
), f"No candidate matched expected percentage {pct}"
@given(
bps=st.integers(min_value=1, max_value=500),
)
@settings(max_examples=100)
def test_basis_points_normalize_to_percentage(self, bps: int) -> None:
"""Property: N basis points always normalizes to N/100 percentage points.
**Validates: Requirements 5.7, 5.8**
"""
text = f"Rates moved {bps} basis points today."
parser = FinancialParser()
results = parser.parse(text)
bps_candidates = [
r for r in results
if r.candidate_type == CandidateType.BASIS_POINTS
and r.normalized_value is not None
]
assert len(bps_candidates) >= 1
expected = bps / 100.0
assert any(
abs(c.normalized_value - expected) < 0.001
for c in bps_candidates
), f"Expected {expected} but got {[c.normalized_value for c in bps_candidates]}"
@given(
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters="$€£¥%.,+-/0123456789",
)),
)
@settings(max_examples=100)
def test_normalized_values_are_always_finite(self, text: str) -> None:
"""Property: Normalized values are always finite floats (no inf, no NaN).
**Validates: Requirements 5.7, 5.8**
"""
parser = FinancialParser()
results = parser.parse(text)
for candidate in results:
if candidate.normalized_value is not None:
assert math.isfinite(candidate.normalized_value), (
f"Non-finite value {candidate.normalized_value} for "
f"{candidate.candidate_type}: '{candidate.literal_value}'"
)
@given(
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters="$€£¥%.,+-/0123456789 \n",
)),
)
@settings(max_examples=100)
def test_offsets_always_map_to_literal(self, text: str) -> None:
"""Property: For any text, candidate offsets always map to the literal value.
**Validates: Requirements 5.1, 5.8**
"""
parser = FinancialParser()
results = parser.parse(text)
for candidate in results:
extracted = text[candidate.start_char:candidate.end_char]
assert extracted == candidate.literal_value, (
f"Offset mismatch: [{candidate.start_char}:{candidate.end_char}] = "
f"'{extracted}' != literal '{candidate.literal_value}'"
)
@given(
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters="$€£¥%.,+-/0123456789 \n",
)),
)
@settings(max_examples=100)
def test_no_overlapping_candidates(self, text: str) -> None:
"""Property: No two candidates have overlapping offsets.
**Validates: Requirements 5.1**
"""
parser = FinancialParser()
results = parser.parse(text)
for i in range(len(results)):
for j in range(i + 1, len(results)):
a = results[i]
b = results[j]
assert a.end_char <= b.start_char or b.end_char <= a.start_char, (
f"Overlap: {a.candidate_type}[{a.start_char}:{a.end_char}] "
f"vs {b.candidate_type}[{b.start_char}:{b.end_char}]"
)
# ---------------------------------------------------------------------------
# Normalizer unit tests
# ---------------------------------------------------------------------------
class TestNormalizerFunctions:
"""Test normalizer helper functions directly."""
def test_normalize_money_billion(self) -> None:
assert normalize_money("$94.9 billion") == pytest.approx(94_900_000_000.0)
def test_normalize_money_million(self) -> None:
assert normalize_money("$45 million") == pytest.approx(45_000_000.0)
def test_normalize_money_per_share(self) -> None:
assert normalize_money("$1.52 per share") == pytest.approx(1.52)
def test_normalize_money_simple(self) -> None:
assert normalize_money("$123.45") == pytest.approx(123.45)
def test_normalize_money_with_commas(self) -> None:
assert normalize_money("$1,234.56") == pytest.approx(1234.56)
def test_normalize_percentage(self) -> None:
assert normalize_percentage("4%") == pytest.approx(4.0)
assert normalize_percentage("-2.5%") == pytest.approx(-2.5)
assert normalize_percentage("+1.2 percent") == pytest.approx(1.2)
def test_normalize_basis_points(self) -> None:
assert normalize_basis_points("25 basis points") == pytest.approx(0.25)
assert normalize_basis_points("50bps") == pytest.approx(0.50)
assert normalize_basis_points("100 bps") == pytest.approx(1.0)
def test_normalize_range(self) -> None:
low, high = normalize_range("$10-$12")
assert low == pytest.approx(10.0)
assert high == pytest.approx(12.0)
def test_normalize_range_to(self) -> None:
low, high = normalize_range("$1.50 to $2.00")
assert low == pytest.approx(1.50)
assert high == pytest.approx(2.00)
def test_normalize_value_dispatches(self) -> None:
assert normalize_value("money", "$94.9 billion") == pytest.approx(94_900_000_000.0)
assert normalize_value("percentage", "4%") == pytest.approx(4.0)
assert normalize_value("basis_points", "25 basis points") == pytest.approx(0.25)
assert normalize_value("eps", "$1.52 per share") == pytest.approx(1.52)
assert normalize_value("ticker", "$AAPL") is None
@@ -0,0 +1,837 @@
"""Tests for symbol resolution: alias index, resolver, and ambiguity handling.
Validates Requirements 5.2, 5.3, 5.4, 5.5, 5.6
"""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.resolution.alias_index import (
AliasIndex,
IndexEntry,
build_alias_index,
)
from services.intelligence_pipeline_v3.resolution.explicit_vs_inferred import (
ClassifiedMentionType,
classify_mention,
to_mention_type,
)
from services.intelligence_pipeline_v3.resolution.models import (
MatchType,
MentionType,
ResolutionCandidate,
UnresolvedMention,
UnresolvedReason,
)
from services.intelligence_pipeline_v3.resolution.symbol_resolver import SymbolResolver
# --- Fixtures ---
def _sample_companies() -> list[dict]:
"""Standard set of test companies matching the seed structure."""
return [
{
"id": "11111111-1111-1111-1111-111111111111",
"ticker": "AAPL",
"legal_name": "Apple Inc.",
"aliases": [
{"alias": "Apple", "alias_type": "brand"},
{"alias": "iPhone", "alias_type": "product"},
],
},
{
"id": "22222222-2222-2222-2222-222222222222",
"ticker": "GOOGL",
"legal_name": "Alphabet Inc.",
"aliases": [
{"alias": "Google", "alias_type": "brand"},
{"alias": "Alphabet", "alias_type": "legal_name"},
{"alias": "YouTube", "alias_type": "product"},
],
},
{
"id": "33333333-3333-3333-3333-333333333333",
"ticker": "MSFT",
"legal_name": "Microsoft Corporation",
"aliases": [
{"alias": "Microsoft", "alias_type": "brand"},
{"alias": "Azure", "alias_type": "product"},
{"alias": "Windows", "alias_type": "product"},
],
},
{
"id": "44444444-4444-4444-4444-444444444444",
"ticker": "META",
"legal_name": "Meta Platforms Inc.",
"aliases": [
{"alias": "Facebook", "alias_type": "brand"},
{"alias": "Instagram", "alias_type": "product"},
{"alias": "WhatsApp", "alias_type": "product"},
],
},
{
"id": "55555555-5555-5555-5555-555555555555",
"ticker": "JPM",
"legal_name": "JPMorgan Chase & Co.",
"aliases": [
{"alias": "JPMorgan", "alias_type": "brand"},
{"alias": "Chase", "alias_type": "brand"},
],
},
{
"id": "66666666-6666-6666-6666-666666666666",
"ticker": "V",
"legal_name": "Visa Inc.",
"aliases": [
{"alias": "Visa", "alias_type": "brand"},
],
},
]
def _companies_with_shared_alias() -> list[dict]:
"""Companies that share an alias, creating ambiguity."""
return [
{
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"ticker": "CARR",
"legal_name": "Carrier Global Corporation",
"aliases": [
{"alias": "Carrier", "alias_type": "brand"},
],
},
{
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"ticker": "CSX",
"legal_name": "CSX Corporation",
"aliases": [
{"alias": "Carrier", "alias_type": "brand"}, # shared alias!
],
},
]
def _companies_with_multiple_shared_aliases() -> list[dict]:
"""Three companies sharing the 'Mercury' alias plus additional overlaps."""
return [
{
"id": "aaa11111-1111-1111-1111-111111111111",
"ticker": "MCY",
"legal_name": "Mercury General Corporation",
"aliases": [
{"alias": "Mercury", "alias_type": "brand"},
{"alias": "Mercury Insurance", "alias_type": "brand"},
],
},
{
"id": "bbb22222-2222-2222-2222-222222222222",
"ticker": "MRCY",
"legal_name": "Mercury Systems Inc.",
"aliases": [
{"alias": "Mercury", "alias_type": "brand"},
{"alias": "Mercury Systems", "alias_type": "brand"},
],
},
{
"id": "ccc33333-3333-3333-3333-333333333333",
"ticker": "MERC",
"legal_name": "Mercer International Inc.",
"aliases": [
{"alias": "Mercury", "alias_type": "brand"}, # 3-way shared
{"alias": "Mercer", "alias_type": "brand"},
],
},
]
@pytest.fixture
def resolver() -> SymbolResolver:
"""Create a resolver loaded with sample companies."""
r = SymbolResolver()
r.load_registry(_sample_companies())
return r
@pytest.fixture
def ambiguous_resolver() -> SymbolResolver:
"""Create a resolver with companies sharing aliases."""
r = SymbolResolver()
r.load_registry(_companies_with_shared_alias())
return r
@pytest.fixture
def multi_ambiguous_resolver() -> SymbolResolver:
"""Create a resolver with three companies sharing one alias."""
r = SymbolResolver()
r.load_registry(_companies_with_multiple_shared_aliases())
return r
# --- Tests: build_alias_index (Task 24.1) ---
class TestBuildAliasIndex:
"""Test the build_alias_index factory function."""
def test_builds_from_company_list(self) -> None:
companies = _sample_companies()
index = build_alias_index(companies)
assert len(index) > 0
def test_indexes_tickers(self) -> None:
companies = _sample_companies()
index = build_alias_index(companies)
entries = index.lookup("AAPL")
assert len(entries) == 1
assert entries[0].ticker == "AAPL"
assert entries[0].match_type == "exact_ticker"
def test_indexes_legal_names(self) -> None:
companies = _sample_companies()
index = build_alias_index(companies)
entries = index.lookup("Microsoft Corporation")
assert len(entries) >= 1
assert any(e.ticker == "MSFT" for e in entries)
def test_indexes_aliases(self) -> None:
companies = _sample_companies()
index = build_alias_index(companies)
entries = index.lookup("Google")
assert len(entries) == 1
assert entries[0].ticker == "GOOGL"
assert entries[0].match_type == "alias"
def test_handles_tuple_aliases(self) -> None:
companies = [
{
"id": "99999999-9999-9999-9999-999999999999",
"ticker": "TEST",
"legal_name": "Test Corp.",
"aliases": [("TestAlias", "brand"), ("AnotherAlias", "product")],
}
]
index = build_alias_index(companies)
entries = index.lookup("TestAlias")
assert len(entries) == 1
assert entries[0].ticker == "TEST"
def test_handles_empty_company_list(self) -> None:
index = build_alias_index([])
assert len(index) == 0
def test_handles_company_without_aliases(self) -> None:
companies = [
{
"id": "88888888-8888-8888-8888-888888888888",
"ticker": "BARE",
"legal_name": "Bare Corp.",
}
]
index = build_alias_index(companies)
entries = index.lookup("BARE")
# "BARE" matches both as ticker and normalized legal name "Bare Corp." → "bare"
assert len(entries) >= 1
assert all(e.company_id == "88888888-8888-8888-8888-888888888888" for e in entries)
def test_case_insensitive_lookup(self) -> None:
companies = _sample_companies()
index = build_alias_index(companies)
entries_upper = index.lookup("AAPL")
entries_lower = index.lookup("aapl")
assert len(entries_upper) == len(entries_lower)
# --- Tests: Exact Ticker Match (Task 24.2) ---
class TestExactTickerMatch:
"""Test resolving by ticker symbol."""
def test_aapl_resolves(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("AAPL")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "AAPL"
assert result.candidates[0].company_id == "11111111-1111-1111-1111-111111111111"
assert result.candidates[0].match_type == MatchType.exact_ticker
assert result.candidates[0].confidence >= 0.9
assert not result.is_ambiguous
def test_googl_resolves(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("GOOGL")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "GOOGL"
assert result.candidates[0].match_type == MatchType.exact_ticker
def test_single_char_ticker(self, resolver: SymbolResolver) -> None:
"""Ticker 'V' (Visa) should resolve correctly."""
result = resolver.resolve("V")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "V"
assert result.candidates[0].name == "Visa Inc."
def test_ambiguity_margin_is_1_for_single_match(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("AAPL")
assert result.ambiguity_margin == 1.0
# --- Tests: Ranked Candidates and Ambiguity Margins (Task 24.2) ---
class TestRankedCandidates:
"""Test that candidates are ranked by confidence with proper margins."""
def test_candidates_sorted_descending(self, ambiguous_resolver: SymbolResolver) -> None:
result = ambiguous_resolver.resolve("Carrier")
# Multiple candidates should be sorted by confidence descending
for i in range(len(result.candidates) - 1):
assert result.candidates[i].confidence >= result.candidates[i + 1].confidence
def test_ambiguity_margin_calculated(self, ambiguous_resolver: SymbolResolver) -> None:
result = ambiguous_resolver.resolve("Carrier")
# Both are alias matches → same confidence → margin = 0
assert result.ambiguity_margin == pytest.approx(0.0)
def test_unambiguous_has_high_margin(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("AAPL")
assert result.ambiguity_margin == 1.0
assert not result.is_ambiguous
def test_three_way_ambiguity(self, multi_ambiguous_resolver: SymbolResolver) -> None:
"""Mercury alias shared by 3 companies → ambiguous with 3 candidates."""
result = multi_ambiguous_resolver.resolve("Mercury")
assert len(result.candidates) == 3
assert result.is_ambiguous
# All have same confidence (alias match), so margin = 0
assert result.ambiguity_margin == pytest.approx(0.0)
def test_unique_alias_not_ambiguous(self, multi_ambiguous_resolver: SymbolResolver) -> None:
"""Mercury Insurance is unique to MCY."""
result = multi_ambiguous_resolver.resolve("Mercury Insurance")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "MCY"
assert not result.is_ambiguous
# --- Tests: Explicit vs Inferred Exposure (Task 24.3) ---
class TestMentionTypes:
"""Test separation of explicit mentions from inferred exposures."""
def test_explicit_mention_default(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("AAPL")
assert result.mention_type == MentionType.explicit
def test_inferred_mention(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("AAPL", mention_type=MentionType.inferred)
assert result.mention_type == MentionType.inferred
assert len(result.candidates) == 1
def test_inferred_preserves_candidates(self, resolver: SymbolResolver) -> None:
explicit = resolver.resolve("Google", mention_type=MentionType.explicit)
inferred = resolver.resolve("Google", mention_type=MentionType.inferred)
# Same candidates, different mention_type
assert len(explicit.candidates) == len(inferred.candidates)
assert explicit.candidates[0].ticker == inferred.candidates[0].ticker
assert explicit.mention_type == MentionType.explicit
assert inferred.mention_type == MentionType.inferred
def test_unresolved_preserves_mention_type(self, resolver: SymbolResolver) -> None:
"""Even empty results carry the mention_type."""
result = resolver.resolve("UnknownCorp", mention_type=MentionType.inferred)
assert result.mention_type == MentionType.inferred
assert len(result.candidates) == 0
def test_mention_type_enum_values(self) -> None:
assert MentionType.explicit.value == "explicit"
assert MentionType.inferred.value == "inferred"
# --- Tests: Unresolved Mentions Preserved (Task 24.4) ---
class TestUnresolvedMentions:
"""Test that unresolved mentions are preserved without invented tickers."""
def test_unknown_company_returns_empty(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Palantir Technologies")
assert len(result.candidates) == 0
assert not result.is_ambiguous
def test_unknown_returns_unresolved_mention(self, resolver: SymbolResolver) -> None:
result = resolver.resolve_or_unresolved(
"Palantir Technologies", start_char=0, end_char=21
)
assert isinstance(result, UnresolvedMention)
assert result.reason == UnresolvedReason.not_in_registry
assert result.literal_text == "Palantir Technologies"
def test_unresolved_preserves_offsets(self, resolver: SymbolResolver) -> None:
result = resolver.resolve_or_unresolved(
"SomeUnknownCo", start_char=42, end_char=55
)
assert isinstance(result, UnresolvedMention)
assert result.start_char == 42
assert result.end_char == 55
def test_no_ticker_invented_for_unknown(self, resolver: SymbolResolver) -> None:
"""The resolver MUST NOT invent a ticker for unresolved mentions."""
result = resolver.resolve("Palantir Technologies")
# No candidates means no ticker was invented
assert len(result.candidates) == 0
# Using resolve_or_unresolved, canonical_id equivalent is None (UnresolvedMention)
unresolved = resolver.resolve_or_unresolved(
"Palantir Technologies", start_char=0, end_char=21
)
assert isinstance(unresolved, UnresolvedMention)
# Verify the literal text is preserved exactly as given
assert unresolved.literal_text == "Palantir Technologies"
def test_empty_mention_returns_empty(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("")
assert len(result.candidates) == 0
def test_gibberish_returns_empty(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("xyzzy123abc")
assert len(result.candidates) == 0
def test_ambiguous_marked_as_unresolved(self, ambiguous_resolver: SymbolResolver) -> None:
"""Ambiguous aliases return UnresolvedMention with reason=ambiguous."""
result = ambiguous_resolver.resolve_or_unresolved(
"Carrier", start_char=10, end_char=17
)
assert isinstance(result, UnresolvedMention)
assert result.reason == UnresolvedReason.ambiguous
assert result.literal_text == "Carrier"
assert result.start_char == 10
assert result.end_char == 17
# --- Tests: Aliases Shared by Multiple Companies (Task 24.5) ---
class TestSharedAliases:
"""Test behavior when aliases are shared by multiple companies."""
def test_shared_alias_returns_multiple_candidates(
self, ambiguous_resolver: SymbolResolver
) -> None:
result = ambiguous_resolver.resolve("Carrier")
assert len(result.candidates) == 2
tickers = {c.ticker for c in result.candidates}
assert "CARR" in tickers
assert "CSX" in tickers
def test_shared_alias_is_ambiguous(self, ambiguous_resolver: SymbolResolver) -> None:
result = ambiguous_resolver.resolve("Carrier")
# Both are alias matches with the same confidence, so margin = 0
assert result.is_ambiguous
assert result.ambiguity_margin < 0.15
def test_three_companies_share_alias(
self, multi_ambiguous_resolver: SymbolResolver
) -> None:
"""Three companies sharing 'Mercury' → all three returned as candidates."""
result = multi_ambiguous_resolver.resolve("Mercury")
assert len(result.candidates) == 3
tickers = {c.ticker for c in result.candidates}
assert "MCY" in tickers
assert "MRCY" in tickers
assert "MERC" in tickers
def test_three_way_shared_alias_ambiguity_margin(
self, multi_ambiguous_resolver: SymbolResolver
) -> None:
"""With 3 same-confidence candidates, margin between top-2 is 0."""
result = multi_ambiguous_resolver.resolve("Mercury")
assert result.ambiguity_margin == pytest.approx(0.0)
assert result.is_ambiguous
def test_unique_alias_among_shared(
self, multi_ambiguous_resolver: SymbolResolver
) -> None:
"""'Mercury Systems' is unique to MRCY even though 'Mercury' is shared."""
result = multi_ambiguous_resolver.resolve("Mercury Systems")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "MRCY"
assert not result.is_ambiguous
assert result.ambiguity_margin == 1.0
def test_shared_alias_all_candidates_have_scores(
self, ambiguous_resolver: SymbolResolver
) -> None:
"""All candidates from a shared alias should have valid confidence scores."""
result = ambiguous_resolver.resolve("Carrier")
for candidate in result.candidates:
assert 0.0 <= candidate.confidence <= 1.0
assert candidate.match_type == MatchType.alias
def test_shared_alias_companies_have_distinct_ids(
self, ambiguous_resolver: SymbolResolver
) -> None:
"""Shared-alias candidates should have unique company_ids."""
result = ambiguous_resolver.resolve("Carrier")
company_ids = [c.company_id for c in result.candidates]
assert len(company_ids) == len(set(company_ids))
def test_ticker_not_shared_even_when_alias_is(
self, ambiguous_resolver: SymbolResolver
) -> None:
"""Direct ticker lookup for CARR is unambiguous even if 'Carrier' is shared."""
result = ambiguous_resolver.resolve("CARR")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "CARR"
assert not result.is_ambiguous
def test_shared_alias_resolve_or_unresolved_returns_unresolved(
self, ambiguous_resolver: SymbolResolver
) -> None:
"""resolve_or_unresolved returns UnresolvedMention for ambiguous aliases."""
result = ambiguous_resolver.resolve_or_unresolved(
"Carrier", start_char=0, end_char=7
)
assert isinstance(result, UnresolvedMention)
assert result.reason == UnresolvedReason.ambiguous
# --- Tests: Exact Name Match ---
class TestExactNameMatch:
"""Test resolving by full legal name."""
def test_apple_inc(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Apple Inc.")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "AAPL"
assert result.candidates[0].match_type == MatchType.exact_name
def test_alphabet_inc(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Alphabet Inc.")
# "Alphabet Inc." → normalized "alphabet" matches the alias entry.
assert len(result.candidates) >= 1
assert result.candidates[0].ticker == "GOOGL"
def test_microsoft_corporation(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Microsoft Corporation")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "MSFT"
# --- Tests: Alias Match ---
class TestAliasMatch:
"""Test resolving by known alias."""
def test_alphabet_alias(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Alphabet")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "GOOGL"
def test_google_alias(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Google")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "GOOGL"
assert result.candidates[0].match_type == MatchType.alias
def test_facebook_alias(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Facebook")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "META"
def test_iphone_product_alias(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("iPhone")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "AAPL"
def test_chase_alias(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Chase")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "JPM"
# --- Tests: Case Insensitivity ---
class TestCaseInsensitivity:
"""Test that matching is case-insensitive."""
def test_ticker_lowercase(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("aapl")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "AAPL"
def test_ticker_mixed_case(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Aapl")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "AAPL"
def test_name_uppercase(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("MICROSOFT CORPORATION")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "MSFT"
def test_alias_mixed_case(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("GOOGLE")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "GOOGL"
def test_alias_all_lower(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("facebook")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "META"
# --- Tests: Suffix Variations ---
class TestSuffixVariations:
"""Test that corporate suffixes (Inc, Corp, LLC) are stripped during matching."""
def test_without_inc(self, resolver: SymbolResolver) -> None:
"""'Apple' without 'Inc.' should still match via alias."""
result = resolver.resolve("Apple")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "AAPL"
def test_with_inc_period(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Apple Inc.")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "AAPL"
def test_with_incorporated(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Apple Incorporated")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "AAPL"
def test_corp_stripped(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Microsoft Corp.")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "MSFT"
def test_corp_full(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Microsoft Corp")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "MSFT"
def test_corporation_stripped(self, resolver: SymbolResolver) -> None:
result = resolver.resolve("Microsoft Corporation")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "MSFT"
def test_llc_suffix(self) -> None:
"""Test that LLC suffix is stripped."""
resolver = SymbolResolver()
resolver.load_registry([
{
"id": "77777777-7777-7777-7777-777777777777",
"ticker": "TEST",
"legal_name": "TestCo LLC",
"aliases": [],
}
])
result = resolver.resolve("TestCo")
assert len(result.candidates) == 1
assert result.candidates[0].ticker == "TEST"
# --- Tests: AliasIndex Directly ---
class TestAliasIndex:
"""Unit tests for the AliasIndex component."""
def test_normalize_strips_inc(self) -> None:
assert AliasIndex.normalize("Apple Inc.") == "apple"
def test_normalize_strips_corporation(self) -> None:
assert AliasIndex.normalize("Microsoft Corporation") == "microsoft"
def test_normalize_strips_llc(self) -> None:
assert AliasIndex.normalize("SomeCompany LLC") == "somecompany"
def test_normalize_strips_limited(self) -> None:
assert AliasIndex.normalize("Acme Limited") == "acme"
def test_normalize_preserves_meaningful_text(self) -> None:
assert AliasIndex.normalize("Google") == "google"
def test_normalize_collapses_whitespace(self) -> None:
assert AliasIndex.normalize(" Apple Inc. ") == "apple"
def test_empty_string(self) -> None:
assert AliasIndex.normalize("") == ""
def test_lookup_returns_empty_for_missing(self) -> None:
idx = AliasIndex()
assert idx.lookup("nonexistent") == []
def test_len(self) -> None:
idx = AliasIndex()
idx.add("Apple", IndexEntry("1", "AAPL", "Apple Inc.", "alias"))
idx.add("Google", IndexEntry("2", "GOOGL", "Alphabet Inc.", "alias"))
assert len(idx) == 2
def test_keys_returns_all_normalized_keys(self) -> None:
idx = AliasIndex()
idx.add("Apple", IndexEntry("1", "AAPL", "Apple Inc.", "alias"))
idx.add("Google", IndexEntry("2", "GOOGL", "Alphabet Inc.", "alias"))
keys = idx.keys()
assert "apple" in keys
assert "google" in keys
# --- Tests: Explicit vs Inferred Classification (Task 24.3) ---
class TestClassifyMention:
"""Test classify_mention separates explicit from inferred exposures."""
def test_explicit_with_direct_mention(self) -> None:
"""Company name in text with no relationship keywords → explicit."""
candidates = [
ResolutionCandidate(
company_id="11111111-1111-1111-1111-111111111111",
ticker="AAPL",
name="Apple Inc.",
confidence=0.95,
match_type=MatchType.exact_ticker,
)
]
context = "Apple announced record quarterly revenue of $94.8 billion."
result = classify_mention("Apple", context, candidates)
assert result == ClassifiedMentionType.explicit_mention
def test_inferred_with_competitor_keyword(self) -> None:
"""Mention surrounded by competitor keywords → inferred."""
candidates = [
ResolutionCandidate(
company_id="33333333-3333-3333-3333-333333333333",
ticker="MSFT",
name="Microsoft Corporation",
confidence=0.80,
match_type=MatchType.alias,
)
]
context = "Apple's main competitor Microsoft may feel pressure from the announcement."
result = classify_mention("Microsoft", context, candidates)
assert result == ClassifiedMentionType.inferred_exposure
def test_inferred_with_supplier_keyword(self) -> None:
"""Mention with supplier relationship keyword → inferred."""
candidates = [
ResolutionCandidate(
company_id="22222222-2222-2222-2222-222222222222",
ticker="NVDA",
name="NVIDIA Corporation",
confidence=0.95,
match_type=MatchType.exact_ticker,
)
]
context = "Tesla's key supplier NVDA could benefit from increased production volumes."
result = classify_mention("NVDA", context, candidates)
assert result == ClassifiedMentionType.inferred_exposure
def test_unresolved_with_no_candidates(self) -> None:
"""No candidates → unresolved."""
context = "Palantir Technologies posted strong growth numbers."
result = classify_mention("Palantir", context, [])
assert result == ClassifiedMentionType.unresolved
def test_explicit_even_with_relationship_word_when_attributed(self) -> None:
"""If explicit attribution keywords are present near the mention, stay explicit."""
candidates = [
ResolutionCandidate(
company_id="33333333-3333-3333-3333-333333333333",
ticker="MSFT",
name="Microsoft Corporation",
confidence=0.90,
match_type=MatchType.exact_name,
)
]
# "Microsoft announced" is explicit attribution even with "competitor" nearby
context = "Microsoft announced earnings that beat competitor expectations."
result = classify_mention("Microsoft", context, candidates)
assert result == ClassifiedMentionType.explicit_mention
def test_inferred_with_peer_keyword(self) -> None:
"""Sector peer reference → inferred."""
candidates = [
ResolutionCandidate(
company_id="44444444-4444-4444-4444-444444444444",
ticker="AMD",
name="Advanced Micro Devices Inc.",
confidence=0.80,
match_type=MatchType.alias,
)
]
context = "NVIDIA's results could impact sector peer AMD through changed market expectations."
result = classify_mention("AMD", context, candidates)
assert result == ClassifiedMentionType.inferred_exposure
def test_explicit_when_mention_not_in_context(self) -> None:
"""If mention not found in context at all, default to explicit (alias match suffices)."""
candidates = [
ResolutionCandidate(
company_id="11111111-1111-1111-1111-111111111111",
ticker="AAPL",
name="Apple Inc.",
confidence=0.95,
match_type=MatchType.exact_ticker,
)
]
# Context doesn't contain the mention text
context = "Revenue increased significantly in Q4."
result = classify_mention("AAPL", context, candidates)
assert result == ClassifiedMentionType.explicit_mention
def test_explicit_with_empty_context(self) -> None:
"""Empty context with valid candidates → explicit (alias resolution is enough)."""
candidates = [
ResolutionCandidate(
company_id="11111111-1111-1111-1111-111111111111",
ticker="AAPL",
name="Apple Inc.",
confidence=0.95,
match_type=MatchType.exact_ticker,
)
]
result = classify_mention("AAPL", "", candidates)
assert result == ClassifiedMentionType.explicit_mention
def test_to_mention_type_explicit(self) -> None:
"""ClassifiedMentionType.explicit_mention → MentionType.explicit."""
assert to_mention_type(ClassifiedMentionType.explicit_mention) == MentionType.explicit
def test_to_mention_type_inferred(self) -> None:
"""ClassifiedMentionType.inferred_exposure → MentionType.inferred."""
assert to_mention_type(ClassifiedMentionType.inferred_exposure) == MentionType.inferred
def test_to_mention_type_unresolved(self) -> None:
"""ClassifiedMentionType.unresolved → MentionType.explicit (default)."""
assert to_mention_type(ClassifiedMentionType.unresolved) == MentionType.explicit
def test_inferred_with_exposure_keyword(self) -> None:
"""Direct use of 'exposure' keyword → inferred."""
candidates = [
ResolutionCandidate(
company_id="55555555-5555-5555-5555-555555555555",
ticker="INTC",
name="Intel Corporation",
confidence=0.80,
match_type=MatchType.alias,
)
]
context = "The tariffs create indirect exposure for INTC through its Asia supply chain."
result = classify_mention("INTC", context, candidates)
assert result == ClassifiedMentionType.inferred_exposure
def test_enum_values(self) -> None:
"""Verify ClassifiedMentionType string values."""
assert ClassifiedMentionType.explicit_mention.value == "explicit_mention"
assert ClassifiedMentionType.inferred_exposure.value == "inferred_exposure"
assert ClassifiedMentionType.unresolved.value == "unresolved"
@@ -0,0 +1,616 @@
"""Tests for the deterministic routing engine.
Covers:
- Hard rules trigger adjudication
- Confidence below threshold triggers adjudication
- Confidence above threshold triggers fast path
- All reasons are assigned correctly
- Property test: same inputs always produce same route (determinism)
- Property test: confidence at exact threshold boundary has deterministic behavior
- Decision storage captures features
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from services.intelligence_pipeline_v3.routing.reasons import (
RouteDecision,
RoutingReason,
)
from services.intelligence_pipeline_v3.routing.router import (
RoutingEngine,
)
from services.intelligence_pipeline_v3.routing.rules import evaluate_hard_rules
from services.intelligence_pipeline_v3.routing.store import RoutingDecisionStore
from services.intelligence_pipeline_v3.routing.thresholds import (
DEFAULT_DOCUMENT_THRESHOLDS,
DEFAULT_EVENT_THRESHOLDS,
DEFAULT_FALLBACK_THRESHOLD,
FastPathThresholds,
evaluate_thresholds,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def engine() -> RoutingEngine:
return RoutingEngine()
@pytest.fixture
def clean_features() -> dict:
"""Confidence features with no issues — should pass fast path."""
return {
"calibrated_confidence": 0.90,
"evidence_coverage": 0.95,
"material_fields_present": True,
}
@pytest.fixture
def clean_markers() -> dict:
"""Ambiguity markers with no issues."""
return {
"unresolved_aliases": 0,
"primary_company_count": 1,
"contradictory_numeric_facts": False,
"conflicting_sentiment": False,
"implied_causal_impact": False,
"guidance_vs_consensus": False,
"long_document_cross_chunk": False,
}
# ---------------------------------------------------------------------------
# 31.1 — Routing reason enums
# ---------------------------------------------------------------------------
class TestRoutingReasonEnums:
"""Test that all required routing reason enums exist and are correct."""
def test_all_reasons_defined(self):
expected = {
"UNRESOLVED_ALIAS",
"MULTIPLE_PRIMARY_COMPANIES",
"CONTRADICTORY_NUMERIC_FACTS",
"CONFLICTING_SENTIMENT",
"IMPLIED_CAUSAL_IMPACT",
"GUIDANCE_VS_CONSENSUS_REQUIRES_REASONING",
"MATERIAL_FIELD_MISSING",
"EVIDENCE_COVERAGE_BELOW_THRESHOLD",
"CALIBRATED_CONFIDENCE_BELOW_THRESHOLD",
"LONG_DOCUMENT_CROSS_CHUNK_RELATION",
"FAST_PATH_ACCEPTED",
}
actual = {r.name for r in RoutingReason}
assert actual == expected
def test_route_decision_values(self):
assert RouteDecision.FAST_PATH.value == "fast_path"
assert RouteDecision.ADJUDICATION.value == "adjudication"
def test_reason_string_values_match_names(self):
"""Reason values should be their name for database storage."""
for reason in RoutingReason:
assert reason.value == reason.name
# ---------------------------------------------------------------------------
# 31.2 — Hard ambiguity/conflict rules
# ---------------------------------------------------------------------------
class TestHardRules:
"""Test that hard rules correctly trigger adjudication reasons."""
def test_no_triggers_returns_empty(self, clean_features, clean_markers):
result = evaluate_hard_rules(clean_features, clean_markers)
assert result == []
def test_unresolved_alias_triggers(self, clean_features, clean_markers):
clean_markers["unresolved_aliases"] = 2
result = evaluate_hard_rules(clean_features, clean_markers)
assert RoutingReason.UNRESOLVED_ALIAS in result
def test_multiple_primary_companies_triggers(self, clean_features, clean_markers):
clean_markers["primary_company_count"] = 3
result = evaluate_hard_rules(clean_features, clean_markers)
assert RoutingReason.MULTIPLE_PRIMARY_COMPANIES in result
def test_contradictory_numeric_facts_triggers(self, clean_features, clean_markers):
clean_markers["contradictory_numeric_facts"] = True
result = evaluate_hard_rules(clean_features, clean_markers)
assert RoutingReason.CONTRADICTORY_NUMERIC_FACTS in result
def test_conflicting_sentiment_triggers(self, clean_features, clean_markers):
clean_markers["conflicting_sentiment"] = True
result = evaluate_hard_rules(clean_features, clean_markers)
assert RoutingReason.CONFLICTING_SENTIMENT in result
def test_implied_causal_impact_triggers(self, clean_features, clean_markers):
clean_markers["implied_causal_impact"] = True
result = evaluate_hard_rules(clean_features, clean_markers)
assert RoutingReason.IMPLIED_CAUSAL_IMPACT in result
def test_guidance_vs_consensus_triggers(self, clean_features, clean_markers):
clean_markers["guidance_vs_consensus"] = True
result = evaluate_hard_rules(clean_features, clean_markers)
assert RoutingReason.GUIDANCE_VS_CONSENSUS_REQUIRES_REASONING in result
def test_material_field_missing_triggers(self, clean_features, clean_markers):
clean_features["material_fields_present"] = False
result = evaluate_hard_rules(clean_features, clean_markers)
assert RoutingReason.MATERIAL_FIELD_MISSING in result
def test_long_document_cross_chunk_triggers(self, clean_features, clean_markers):
clean_markers["long_document_cross_chunk"] = True
result = evaluate_hard_rules(clean_features, clean_markers)
assert RoutingReason.LONG_DOCUMENT_CROSS_CHUNK_RELATION in result
def test_multiple_triggers_accumulate(self, clean_features, clean_markers):
clean_markers["unresolved_aliases"] = 1
clean_markers["conflicting_sentiment"] = True
clean_markers["implied_causal_impact"] = True
result = evaluate_hard_rules(clean_features, clean_markers)
assert len(result) == 3
assert RoutingReason.UNRESOLVED_ALIAS in result
assert RoutingReason.CONFLICTING_SENTIMENT in result
assert RoutingReason.IMPLIED_CAUSAL_IMPACT in result
def test_hard_rules_override_high_confidence(self, clean_markers):
"""Even with perfect confidence, hard rules force adjudication."""
features = {
"calibrated_confidence": 1.0,
"evidence_coverage": 1.0,
"material_fields_present": True,
}
clean_markers["contradictory_numeric_facts"] = True
result = evaluate_hard_rules(features, clean_markers)
assert RoutingReason.CONTRADICTORY_NUMERIC_FACTS in result
# ---------------------------------------------------------------------------
# 31.3 — Calibrated fast-path thresholds
# ---------------------------------------------------------------------------
class TestThresholds:
"""Test threshold evaluation by document and event type."""
def test_default_article_threshold(self):
assert DEFAULT_DOCUMENT_THRESHOLDS["article"] == 0.80
def test_default_filing_threshold(self):
assert DEFAULT_DOCUMENT_THRESHOLDS["filing"] == 0.70
def test_default_transcript_threshold(self):
assert DEFAULT_DOCUMENT_THRESHOLDS["transcript"] == 0.75
def test_confidence_above_threshold_is_fast_path(self):
thresholds = FastPathThresholds()
result = evaluate_thresholds(0.85, "article", None, thresholds)
assert result == RouteDecision.FAST_PATH
def test_confidence_below_threshold_is_adjudication(self):
thresholds = FastPathThresholds()
result = evaluate_thresholds(0.75, "article", None, thresholds)
assert result == RouteDecision.ADJUDICATION
def test_confidence_at_exact_threshold_is_fast_path(self):
"""Boundary: confidence == threshold passes fast path."""
thresholds = FastPathThresholds()
result = evaluate_thresholds(0.80, "article", None, thresholds)
assert result == RouteDecision.FAST_PATH
def test_event_type_overrides_document_type(self):
thresholds = FastPathThresholds()
# guidance_change has threshold 0.65, article has 0.80
# With event_type, the event threshold should apply
result = evaluate_thresholds(0.70, "article", "guidance_change", thresholds)
assert result == RouteDecision.FAST_PATH
def test_unknown_document_type_uses_fallback(self):
thresholds = FastPathThresholds()
result = evaluate_thresholds(0.79, "unknown_type", None, thresholds)
assert result == RouteDecision.ADJUDICATION # fallback is 0.80
def test_unknown_event_type_falls_through_to_document(self):
thresholds = FastPathThresholds()
# Unknown event, known document type
result = evaluate_thresholds(0.72, "filing", "unknown_event", thresholds)
assert result == RouteDecision.FAST_PATH # filing threshold is 0.70
def test_custom_thresholds(self):
thresholds = FastPathThresholds(
document_thresholds={"custom_doc": 0.50},
event_thresholds={"custom_event": 0.30},
fallback_threshold=0.90,
)
assert evaluate_thresholds(0.50, "custom_doc", None, thresholds) == RouteDecision.FAST_PATH
assert evaluate_thresholds(0.49, "custom_doc", None, thresholds) == RouteDecision.ADJUDICATION
assert evaluate_thresholds(0.30, "other", "custom_event", thresholds) == RouteDecision.FAST_PATH
def test_resolve_threshold_priority(self):
thresholds = FastPathThresholds()
# Event type takes priority
threshold = thresholds.resolve_threshold("article", "earnings_beat")
assert threshold == DEFAULT_EVENT_THRESHOLDS["earnings_beat"]
# Document type when no event
threshold = thresholds.resolve_threshold("article", None)
assert threshold == DEFAULT_DOCUMENT_THRESHOLDS["article"]
# Fallback for unknown
threshold = thresholds.resolve_threshold("mystery", None)
assert threshold == DEFAULT_FALLBACK_THRESHOLD
# ---------------------------------------------------------------------------
# 31.4 — Store every route decision and feature snapshot
# ---------------------------------------------------------------------------
class TestRoutingDecisionStore:
"""Test that decisions are stored with full feature snapshots."""
def test_store_and_retrieve_by_pipeline_run(self, engine, clean_features, clean_markers):
store = RoutingDecisionStore()
run_id = uuid4()
doc_id = uuid4()
decision = engine.route(
pipeline_run_id=run_id,
document_id=doc_id,
confidence_features=clean_features,
ambiguity_markers=clean_markers,
document_type="article",
)
store.store(decision)
retrieved = store.get_by_pipeline_run(run_id)
assert len(retrieved) == 1
assert retrieved[0].id == decision.id
def test_decision_captures_confidence_snapshot(self, engine, clean_features, clean_markers):
run_id = uuid4()
doc_id = uuid4()
decision = engine.route(
pipeline_run_id=run_id,
document_id=doc_id,
confidence_features=clean_features,
ambiguity_markers=clean_markers,
document_type="article",
)
assert "confidence_features" in decision.confidence_snapshot
assert "ambiguity_markers" in decision.confidence_snapshot
assert "thresholds_version" in decision.confidence_snapshot
assert decision.confidence_snapshot["confidence_features"] == clean_features
assert decision.confidence_snapshot["ambiguity_markers"] == clean_markers
def test_store_multiple_decisions_same_run(self, engine, clean_features, clean_markers):
store = RoutingDecisionStore()
run_id = uuid4()
for _ in range(3):
decision = engine.route(
pipeline_run_id=run_id,
document_id=uuid4(),
confidence_features=clean_features,
ambiguity_markers=clean_markers,
document_type="article",
)
store.store(decision)
assert len(store.get_by_pipeline_run(run_id)) == 3
assert store.count() == 3
def test_get_by_unknown_run_returns_empty(self):
store = RoutingDecisionStore()
assert store.get_by_pipeline_run(uuid4()) == []
def test_decision_has_timestamp(self, engine, clean_features, clean_markers):
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=clean_features,
ambiguity_markers=clean_markers,
document_type="article",
)
assert decision.decided_at is not None
assert decision.decided_at.tzinfo is not None # UTC-aware
def test_decision_is_immutable(self, engine, clean_features, clean_markers):
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=clean_features,
ambiguity_markers=clean_markers,
document_type="article",
)
with pytest.raises(Exception): # frozen dataclass
decision.route = RouteDecision.ADJUDICATION # type: ignore[misc]
# ---------------------------------------------------------------------------
# Integration: full routing engine
# ---------------------------------------------------------------------------
class TestRoutingEngine:
"""Integration tests for the full routing path."""
def test_clean_document_gets_fast_path(self, engine, clean_features, clean_markers):
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=clean_features,
ambiguity_markers=clean_markers,
document_type="article",
)
assert decision.route == RouteDecision.FAST_PATH
assert RoutingReason.FAST_PATH_ACCEPTED in decision.reasons
def test_hard_rule_forces_adjudication(self, engine, clean_features, clean_markers):
clean_markers["contradictory_numeric_facts"] = True
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=clean_features,
ambiguity_markers=clean_markers,
document_type="article",
)
assert decision.route == RouteDecision.ADJUDICATION
assert RoutingReason.CONTRADICTORY_NUMERIC_FACTS in decision.reasons
def test_low_confidence_triggers_adjudication(self, engine, clean_markers):
features = {
"calibrated_confidence": 0.50,
"evidence_coverage": 0.95,
"material_fields_present": True,
}
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=features,
ambiguity_markers=clean_markers,
document_type="article",
)
assert decision.route == RouteDecision.ADJUDICATION
assert RoutingReason.CALIBRATED_CONFIDENCE_BELOW_THRESHOLD in decision.reasons
def test_low_evidence_coverage_triggers_adjudication(self, engine, clean_markers):
features = {
"calibrated_confidence": 0.95,
"evidence_coverage": 0.30,
"material_fields_present": True,
}
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=features,
ambiguity_markers=clean_markers,
document_type="article",
)
assert decision.route == RouteDecision.ADJUDICATION
assert RoutingReason.EVIDENCE_COVERAGE_BELOW_THRESHOLD in decision.reasons
def test_hard_rules_take_priority_over_threshold(self, engine):
"""Hard rules short-circuit — threshold is not even evaluated."""
features = {
"calibrated_confidence": 0.95,
"evidence_coverage": 0.95,
"material_fields_present": True,
}
markers = {
"unresolved_aliases": 1,
"primary_company_count": 1,
"contradictory_numeric_facts": False,
"conflicting_sentiment": False,
"implied_causal_impact": False,
"guidance_vs_consensus": False,
"long_document_cross_chunk": False,
}
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=features,
ambiguity_markers=markers,
document_type="article",
)
assert decision.route == RouteDecision.ADJUDICATION
assert RoutingReason.UNRESOLVED_ALIAS in decision.reasons
# Should NOT contain threshold reason since hard rules short-circuited
assert RoutingReason.CALIBRATED_CONFIDENCE_BELOW_THRESHOLD not in decision.reasons
def test_event_type_affects_threshold(self, engine, clean_markers):
"""Filing with merger event gets easier threshold (0.60)."""
features = {
"calibrated_confidence": 0.62,
"evidence_coverage": 0.80,
"material_fields_present": True,
}
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=features,
ambiguity_markers=clean_markers,
document_type="filing",
event_type="merger_acquisition",
)
assert decision.route == RouteDecision.FAST_PATH
# ---------------------------------------------------------------------------
# 31.5 — Property tests for determinism and threshold boundaries
# ---------------------------------------------------------------------------
# Strategy for generating valid confidence features
confidence_features_strategy = st.fixed_dictionaries({
"calibrated_confidence": st.floats(min_value=0.0, max_value=1.0),
"evidence_coverage": st.floats(min_value=0.0, max_value=1.0),
"material_fields_present": st.booleans(),
})
# Strategy for generating ambiguity markers
ambiguity_markers_strategy = st.fixed_dictionaries({
"unresolved_aliases": st.integers(min_value=0, max_value=10),
"primary_company_count": st.integers(min_value=0, max_value=5),
"contradictory_numeric_facts": st.booleans(),
"conflicting_sentiment": st.booleans(),
"implied_causal_impact": st.booleans(),
"guidance_vs_consensus": st.booleans(),
"long_document_cross_chunk": st.booleans(),
})
document_type_strategy = st.sampled_from(
["article", "filing", "transcript", "press_release", "macro_event", "unknown"]
)
event_type_strategy = st.one_of(
st.none(),
st.sampled_from([
"earnings_beat", "earnings_miss", "guidance_change",
"management_change", "merger_acquisition", "regulatory_action",
"product_launch", "legal_action", "rating_change", "supply_chain",
"unknown_event",
]),
)
class TestDeterminismProperty:
"""Property test: same inputs always produce the same route.
**Validates: Requirements 10.5**
"""
@settings(max_examples=100)
@given(
confidence_features=confidence_features_strategy,
ambiguity_markers=ambiguity_markers_strategy,
document_type=document_type_strategy,
event_type=event_type_strategy,
)
def test_same_inputs_always_same_route(
self,
confidence_features: dict,
ambiguity_markers: dict,
document_type: str,
event_type: str | None,
):
"""Route decisions are deterministic: same inputs → same output."""
engine = RoutingEngine()
run_id = uuid4()
doc_id = uuid4()
decision_1 = engine.route(
pipeline_run_id=run_id,
document_id=doc_id,
confidence_features=confidence_features,
ambiguity_markers=ambiguity_markers,
document_type=document_type,
event_type=event_type,
)
decision_2 = engine.route(
pipeline_run_id=run_id,
document_id=doc_id,
confidence_features=confidence_features,
ambiguity_markers=ambiguity_markers,
document_type=document_type,
event_type=event_type,
)
assert decision_1.route == decision_2.route
assert decision_1.reasons == decision_2.reasons
@settings(max_examples=100)
@given(
confidence_features=confidence_features_strategy,
ambiguity_markers=ambiguity_markers_strategy,
document_type=document_type_strategy,
event_type=event_type_strategy,
)
def test_route_is_always_valid_enum(
self,
confidence_features: dict,
ambiguity_markers: dict,
document_type: str,
event_type: str | None,
):
"""Route decision is always a valid RouteDecision enum value."""
engine = RoutingEngine()
decision = engine.route(
pipeline_run_id=uuid4(),
document_id=uuid4(),
confidence_features=confidence_features,
ambiguity_markers=ambiguity_markers,
document_type=document_type,
event_type=event_type,
)
assert decision.route in (RouteDecision.FAST_PATH, RouteDecision.ADJUDICATION)
assert len(decision.reasons) > 0
class TestThresholdBoundaryProperty:
"""Property test: confidence at exact threshold boundary is deterministic.
**Validates: Requirements 10.5, 11.6**
"""
@settings(max_examples=100)
@given(
document_type=st.sampled_from(list(DEFAULT_DOCUMENT_THRESHOLDS.keys())),
)
def test_at_threshold_is_always_fast_path(self, document_type: str):
"""Confidence exactly at threshold always results in fast path."""
thresholds = FastPathThresholds()
threshold_value = thresholds.resolve_threshold(document_type, None)
# At the boundary
result = evaluate_thresholds(threshold_value, document_type, None, thresholds)
assert result == RouteDecision.FAST_PATH
@settings(max_examples=100)
@given(
document_type=st.sampled_from(list(DEFAULT_DOCUMENT_THRESHOLDS.keys())),
epsilon=st.floats(min_value=1e-15, max_value=0.1),
)
def test_below_threshold_is_always_adjudication(
self, document_type: str, epsilon: float
):
"""Confidence below threshold always results in adjudication."""
thresholds = FastPathThresholds()
threshold_value = thresholds.resolve_threshold(document_type, None)
below = threshold_value - epsilon
if below >= 0.0:
result = evaluate_thresholds(below, document_type, None, thresholds)
assert result == RouteDecision.ADJUDICATION
@settings(max_examples=100)
@given(
document_type=st.sampled_from(list(DEFAULT_DOCUMENT_THRESHOLDS.keys())),
epsilon=st.floats(min_value=1e-15, max_value=0.1),
)
def test_above_threshold_is_always_fast_path(
self, document_type: str, epsilon: float
):
"""Confidence above threshold always results in fast path."""
thresholds = FastPathThresholds()
threshold_value = thresholds.resolve_threshold(document_type, None)
above = threshold_value + epsilon
if above <= 1.0:
result = evaluate_thresholds(above, document_type, None, thresholds)
assert result == RouteDecision.FAST_PATH
@@ -0,0 +1 @@
"""Tests for the Intelligence Pipeline v3 sentence-aware segmenter."""
@@ -0,0 +1,411 @@
"""Unit tests and property tests for the sentence-aware segmenter.
Tests cover:
- Basic segmentation with correct offsets (21.1)
- Document-type-specific strategies (21.2)
- Filing section and transcript speaker preservation (21.3)
- Boilerplate scoring (21.4)
- No truncation for long documents (21.5)
- Property tests for offset mapping, reconstruction, and checksums (21.6)
"""
from __future__ import annotations
import hashlib
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from services.intelligence_pipeline_v3.segmenter import (
ArticleStrategy,
FilingStrategy,
MacroEventStrategy,
Segmenter,
TranscriptStrategy,
score_boilerplate,
)
from services.intelligence_pipeline_v3.segmenter.strategies import get_strategy
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def segmenter() -> Segmenter:
return Segmenter()
SAMPLE_ARTICLE = (
"Apple reported record revenue of $123.9 billion for Q1 2024. "
"The company beat analyst expectations by a wide margin. "
"CEO Tim Cook said growth was driven by iPhone and services. "
"Shares rose 3% in after-hours trading.\n\n"
"Meanwhile, Microsoft also reported strong results. "
"Azure cloud revenue grew 28% year-over-year. "
"The company expects continued momentum in AI workloads."
)
SAMPLE_FILING = (
"Item 1. Business\n\n"
"The company is a global technology leader. "
"We operate in three segments. "
"Our products serve enterprise customers.\n\n"
"Item 2. Properties\n\n"
"We own facilities in 15 countries. "
"Our headquarters is in San Jose, California. "
"We lease approximately 5 million square feet.\n\n"
"Item 7. Management's Discussion and Analysis\n\n"
"Revenue increased 15% to $50 billion. "
"Operating expenses grew 8% driven by R&D investment. "
"Net income was $12 billion, up from $10 billion. "
"We expect continued growth in our cloud segment."
)
SAMPLE_TRANSCRIPT = (
"OPERATOR: Welcome to the Q4 2024 earnings call. "
"I would now like to turn the call over to Tim Cook.\n\n"
"Tim Cook - CEO: Thank you. "
"We are pleased to report another record quarter. "
"Revenue reached $123.9 billion. "
"Services revenue hit an all-time high.\n\n"
"Luca Maestri - CFO: Looking at our financials, "
"gross margin expanded to 46.6%. "
"Operating cash flow was $40 billion.\n\n"
"OPERATOR: We will now take questions from analysts."
)
# ---------------------------------------------------------------------------
# 21.1 — Preserve source offsets and checksums
# ---------------------------------------------------------------------------
class TestSourceOffsetsAndChecksums:
"""Test that chunks preserve exact source offsets and have valid checksums."""
def test_chunk_text_matches_source_offsets(self, segmenter: Segmenter) -> None:
"""Each chunk.text must exactly equal source[start_char:end_char]."""
chunks = segmenter.segment(SAMPLE_ARTICLE, "article", "doc-001")
for chunk in chunks:
assert chunk.text == SAMPLE_ARTICLE[chunk.start_char:chunk.end_char]
def test_chunk_checksum_is_sha256(self, segmenter: Segmenter) -> None:
"""Checksum must be SHA-256 of chunk text."""
chunks = segmenter.segment(SAMPLE_ARTICLE, "article", "doc-001")
for chunk in chunks:
expected = hashlib.sha256(chunk.text.encode("utf-8")).hexdigest()
assert chunk.checksum == expected
def test_chunk_id_is_deterministic(self, segmenter: Segmenter) -> None:
"""chunk_id is {document_id}:{start_char}."""
chunks = segmenter.segment(SAMPLE_ARTICLE, "article", "doc-001")
for chunk in chunks:
assert chunk.chunk_id == f"doc-001:{chunk.start_char}"
def test_empty_text_returns_no_chunks(self, segmenter: Segmenter) -> None:
"""Empty string produces no chunks."""
assert segmenter.segment("", "article") == []
def test_single_sentence_produces_one_chunk(self, segmenter: Segmenter) -> None:
"""A short text produces exactly one chunk."""
text = "Apple stock rose 5% today."
chunks = segmenter.segment(text, "article", "short-doc")
assert len(chunks) == 1
assert chunks[0].text == text
assert chunks[0].start_char == 0
assert chunks[0].end_char == len(text)
# ---------------------------------------------------------------------------
# 21.2 — Document-type-specific chunk strategies
# ---------------------------------------------------------------------------
class TestDocumentTypeStrategies:
"""Test that each document type uses its own strategy."""
def test_article_uses_article_strategy(self) -> None:
strategy = get_strategy("article")
assert strategy is ArticleStrategy
def test_news_uses_article_strategy(self) -> None:
strategy = get_strategy("news")
assert strategy is ArticleStrategy
def test_filing_uses_filing_strategy(self) -> None:
strategy = get_strategy("filing")
assert strategy is FilingStrategy
def test_transcript_uses_transcript_strategy(self) -> None:
strategy = get_strategy("transcript")
assert strategy is TranscriptStrategy
def test_macro_event_uses_macro_strategy(self) -> None:
strategy = get_strategy("macro_event")
assert strategy is MacroEventStrategy
def test_unknown_type_uses_default(self) -> None:
strategy = get_strategy("unknown_type_xyz")
assert strategy is ArticleStrategy
def test_macro_chunks_are_smaller(self, segmenter: Segmenter) -> None:
"""Macro event strategy produces smaller chunks than filing strategy."""
# Generate a long text
long_text = "This is a sentence about macro events. " * 200
macro_chunks = segmenter.segment(long_text, "macro_event", "macro-1")
filing_chunks = segmenter.segment(long_text, "filing", "filing-1")
if len(macro_chunks) > 1 and len(filing_chunks) > 1:
avg_macro = sum(len(c.text) for c in macro_chunks) / len(macro_chunks)
avg_filing = sum(len(c.text) for c in filing_chunks) / len(filing_chunks)
assert avg_macro < avg_filing
# ---------------------------------------------------------------------------
# 21.3 — Preserve filing sections and transcript speakers
# ---------------------------------------------------------------------------
class TestFilingSectionsAndSpeakers:
"""Test that filing sections and transcript speakers are preserved."""
def test_filing_section_path_assigned(self, segmenter: Segmenter) -> None:
"""Filing chunks should have section_path based on Item headers."""
chunks = segmenter.segment(SAMPLE_FILING, "filing", "filing-001")
# At least one chunk should have a section path
sections_found = [c for c in chunks if c.section_path]
assert len(sections_found) > 0
def test_filing_section_contains_item_headers(self, segmenter: Segmenter) -> None:
"""Filing section paths should reference Item headers."""
chunks = segmenter.segment(SAMPLE_FILING, "filing", "filing-001")
all_sections = set()
for c in chunks:
for s in c.section_path:
all_sections.add(s)
# Should find at least some of the Item headers
assert any("Item 1" in s for s in all_sections) or any("Item 2" in s for s in all_sections)
def test_transcript_speaker_assigned(self, segmenter: Segmenter) -> None:
"""Transcript chunks should have speaker labels."""
chunks = segmenter.segment(SAMPLE_TRANSCRIPT, "transcript", "tx-001")
speakers_found = [c for c in chunks if c.speaker]
assert len(speakers_found) > 0
def test_transcript_speaker_names_correct(self, segmenter: Segmenter) -> None:
"""Speaker names should match those in the transcript."""
chunks = segmenter.segment(SAMPLE_TRANSCRIPT, "transcript", "tx-001")
all_speakers = {c.speaker for c in chunks if c.speaker}
# Should find at least one of the speakers
assert any("Tim Cook" in s or "OPERATOR" in s or "Luca Maestri" in s for s in all_speakers)
def test_article_has_no_speaker(self, segmenter: Segmenter) -> None:
"""Article chunks should not have speaker metadata."""
chunks = segmenter.segment(SAMPLE_ARTICLE, "article", "art-001")
for chunk in chunks:
assert chunk.speaker is None
# ---------------------------------------------------------------------------
# 21.4 — Mark boilerplate and duplicate chunks
# ---------------------------------------------------------------------------
class TestBoilerplateDetection:
"""Test boilerplate scoring."""
def test_forward_looking_boilerplate(self) -> None:
"""Forward-looking statements disclaimer scores high."""
text = (
"This press release contains forward-looking statements. "
"Actual results may differ materially from expectations. "
"All rights reserved. © 2024 Company Inc."
)
score = score_boilerplate(text)
assert score >= 0.5
def test_factual_content_scores_low(self) -> None:
"""Factual financial content scores low."""
text = (
"Revenue increased 23% year-over-year to $45.2 billion. "
"Earnings per share were $2.18, beating consensus of $2.05. "
"The company raised full-year guidance to $180 billion."
)
score = score_boilerplate(text)
assert score < 0.3
def test_boilerplate_score_capped_at_one(self) -> None:
"""Score never exceeds 1.0."""
text = (
"Forward-looking statements disclaimer. Safe harbor. "
"Copyright 2024. All rights reserved. Disclaimer applies. "
"This press release contains certain information. "
"Actual results may differ materially. Not an offer or solicitation."
)
score = score_boilerplate(text)
assert score <= 1.0
def test_empty_text_scores_zero(self) -> None:
"""Empty text scores 0.0."""
assert score_boilerplate("") == 0.0
assert score_boilerplate(" \n\t ") == 0.0
def test_segmenter_assigns_boilerplate_scores(self, segmenter: Segmenter) -> None:
"""Chunks from segmenter have boilerplate_score populated."""
text = (
"Revenue grew 20% this quarter. Strong performance across all segments.\n\n"
"This press release contains forward-looking statements. "
"Actual results may differ materially from those anticipated."
)
chunks = segmenter.segment(text, "article", "bp-001")
# All chunks should have a score between 0 and 1
for chunk in chunks:
assert 0.0 <= chunk.boilerplate_score <= 1.0
# ---------------------------------------------------------------------------
# 21.5 — Remove the 8,000-character truncation from v3
# ---------------------------------------------------------------------------
class TestNoTruncation:
"""Test that long documents are NOT truncated."""
def test_long_document_produces_many_chunks(self, segmenter: Segmenter) -> None:
"""A 50,000-char document should produce multiple chunks, not be truncated."""
# Create a document well beyond 8,000 chars
sentences = [f"Sentence number {i} with some financial data about revenue growth. " for i in range(1000)]
long_text = " ".join(sentences)
assert len(long_text) > 50000
chunks = segmenter.segment(long_text, "article", "long-doc")
# Should have many chunks covering the full document
assert len(chunks) > 5
# Last chunk should reach near the end of the document
assert chunks[-1].end_char == len(long_text)
def test_full_coverage_of_long_document(self, segmenter: Segmenter) -> None:
"""Every character in a long document should be covered by at least one chunk."""
sentences = [f"Market analysis point {i} shows interesting trends. " for i in range(500)]
long_text = " ".join(sentences)
chunks = segmenter.segment(long_text, "article", "coverage-doc")
# First chunk starts at 0 or very near it
assert chunks[0].start_char == 0
# Last chunk ends at document end
assert chunks[-1].end_char == len(long_text)
def test_beyond_8000_chars_content_preserved(self, segmenter: Segmenter) -> None:
"""Content after 8000 chars is preserved in chunks (not truncated)."""
# Build text where important content is after 8000 chars
padding = "Filler content for padding. " * 400 # ~11,200 chars
important = "CRITICAL EARNINGS BEAT $5.00 EPS versus $4.50 expected."
text = padding + important
chunks = segmenter.segment(text, "article", "no-trunc")
# The important content should appear in at least one chunk
all_text = "".join(c.text[c.overlap_left:] for c in chunks)
assert "CRITICAL EARNINGS BEAT" in all_text
# ---------------------------------------------------------------------------
# 21.6 — Property tests proving chunk/evidence span mapping
# ---------------------------------------------------------------------------
class TestPropertyBasedSegmenter:
"""Property-based tests for segmenter invariants.
**Validates: Requirements 4.1, 4.2, 4.6**
"""
@given(text=st.text(min_size=1, max_size=20000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters=".!? \n",
)))
@settings(max_examples=100)
def test_every_chunk_maps_to_source_text(self, text: str) -> None:
"""Property: For any text, chunk.text == source[chunk.start_char:chunk.end_char].
**Validates: Requirements 4.1, 4.6**
"""
segmenter = Segmenter()
chunks = segmenter.segment(text, "article", "prop-test")
for chunk in chunks:
assert chunk.text == text[chunk.start_char:chunk.end_char], (
f"Chunk at [{chunk.start_char}:{chunk.end_char}] does not match source"
)
@given(text=st.text(min_size=1, max_size=20000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters=".!? \n",
)))
@settings(max_examples=100)
def test_chunks_cover_full_document(self, text: str) -> None:
"""Property: The non-overlapping core of chunks covers the entire source.
**Validates: Requirements 4.1, 4.2**
"""
segmenter = Segmenter()
chunks = segmenter.segment(text, "article", "cover-test")
if not chunks:
# Empty/whitespace-only text may produce no chunks
assert not text.strip()
return
# First chunk starts at 0
assert chunks[0].start_char == 0
# Last chunk ends at document length
assert chunks[-1].end_char == len(text)
# Chunks must be ordered and cover the full range
# The core (non-overlap) portions should cover without gaps
# Due to overlap, adjacent chunks' starts may be <= previous chunk's end
for i in range(1, len(chunks)):
# Each chunk's start (adjusted for overlap) should not leave gaps
core_start = chunks[i].start_char + chunks[i].overlap_left
prev_end = chunks[i - 1].end_char
assert core_start <= prev_end, (
f"Gap between chunk {i-1} end ({prev_end}) and chunk {i} core start ({core_start})"
)
@given(text=st.text(min_size=1, max_size=10000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters=".!? \n",
)))
@settings(max_examples=100)
def test_checksum_matches_sha256_of_text(self, text: str) -> None:
"""Property: Checksum is always SHA-256 of chunk.text.
**Validates: Requirements 4.1**
"""
segmenter = Segmenter()
chunks = segmenter.segment(text, "article", "checksum-test")
for chunk in chunks:
expected = hashlib.sha256(chunk.text.encode("utf-8")).hexdigest()
assert chunk.checksum == expected, (
f"Checksum mismatch for chunk {chunk.chunk_id}"
)
@given(
text=st.text(min_size=10, max_size=15000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters=".!? \n",
)),
doc_type=st.sampled_from(["article", "filing", "transcript", "macro_event"]),
)
@settings(max_examples=100)
def test_all_document_types_preserve_offsets(self, text: str, doc_type: str) -> None:
"""Property: Offset invariant holds for all document types.
**Validates: Requirements 4.2, 4.3**
"""
segmenter = Segmenter()
chunks = segmenter.segment(text, doc_type, "multi-type-test")
for chunk in chunks:
assert chunk.text == text[chunk.start_char:chunk.end_char]
assert chunk.document_type == doc_type
@@ -0,0 +1,857 @@
"""Tests for company-specific sentiment analysis.
Validates:
- Evidence grouping by company (including relations)
- FinBERT adapter returns valid probability distributions
- Mixed sentiment detection from evidence-group disagreement
- Non-mixed when evidence agrees
- Probability distributions sum to ~1.0
- Calibration passthrough
- SentimentScorer integration
- TextSentiment per-text scoring
- Aggregation module
"""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.sentiment.aggregation import (
MIXED_DISAGREEMENT_THRESHOLD,
aggregate_evidence_sentiments,
)
from services.intelligence_pipeline_v3.sentiment.calibrator import SentimentCalibrator
from services.intelligence_pipeline_v3.sentiment.evidence_groups import build_evidence_groups
from services.intelligence_pipeline_v3.sentiment.finbert_adapter import FinBERTAdapter
from services.intelligence_pipeline_v3.sentiment.mixed_sentiment import (
DISAGREEMENT_THRESHOLD,
compute_mixed_sentiment,
)
from services.intelligence_pipeline_v3.sentiment.models import (
CompanySentimentResult,
EvidenceGroup,
SentimentBatchResult,
TextSentiment,
)
from services.intelligence_pipeline_v3.sentiment.sentiment_scorer import (
SentimentScorer,
)
class TestEvidenceGroups:
"""Test evidence grouping by company."""
def test_single_company_single_evidence(self):
entities = [{"company_id": "AAPL", "evidence_id": "ev1"}]
evidence_spans = {"ev1": "Apple reported strong earnings."}
groups = build_evidence_groups(entities, evidence_spans)
assert "AAPL" in groups
assert groups["AAPL"].company_id == "AAPL"
assert groups["AAPL"].evidence_ids == ["ev1"]
assert groups["AAPL"].texts == ["Apple reported strong earnings."]
def test_single_company_multiple_evidence(self):
entities = [
{"company_id": "AAPL", "evidence_id": "ev1"},
{"company_id": "AAPL", "evidence_id": "ev2"},
]
evidence_spans = {
"ev1": "Apple beat expectations.",
"ev2": "iPhone sales surged.",
}
groups = build_evidence_groups(entities, evidence_spans)
assert "AAPL" in groups
assert len(groups["AAPL"].evidence_ids) == 2
assert "ev1" in groups["AAPL"].evidence_ids
assert "ev2" in groups["AAPL"].evidence_ids
def test_multiple_companies(self):
entities = [
{"company_id": "AAPL", "evidence_id": "ev1"},
{"company_id": "GOOGL", "evidence_id": "ev2"},
]
evidence_spans = {
"ev1": "Apple gained market share.",
"ev2": "Google's ad revenue declined.",
}
groups = build_evidence_groups(entities, evidence_spans)
assert len(groups) == 2
assert "AAPL" in groups
assert "GOOGL" in groups
def test_shared_evidence_across_companies(self):
"""A span mentioning multiple companies should appear in both groups."""
entities = [
{"company_id": "AAPL", "evidence_id": "ev1"},
{"company_id": "GOOGL", "evidence_id": "ev1"},
]
evidence_spans = {"ev1": "Apple and Google both reported growth."}
groups = build_evidence_groups(entities, evidence_spans)
assert "AAPL" in groups
assert "GOOGL" in groups
assert "ev1" in groups["AAPL"].evidence_ids
assert "ev1" in groups["GOOGL"].evidence_ids
def test_entities_without_company_id_skipped(self):
entities = [
{"company_id": None, "evidence_id": "ev1"},
{"company_id": "AAPL", "evidence_id": "ev2"},
]
evidence_spans = {
"ev1": "Some generic text.",
"ev2": "Apple expanded.",
}
groups = build_evidence_groups(entities, evidence_spans)
assert len(groups) == 1
assert "AAPL" in groups
def test_missing_evidence_span_excluded(self):
"""Entity referencing non-existent evidence span is excluded."""
entities = [{"company_id": "AAPL", "evidence_id": "ev_missing"}]
evidence_spans = {"ev1": "Some text."}
groups = build_evidence_groups(entities, evidence_spans)
assert len(groups) == 0
def test_empty_inputs(self):
groups = build_evidence_groups([], {})
assert len(groups) == 0
def test_deduplicates_evidence_ids_per_company(self):
"""Same evidence_id referenced twice for same company shouldn't duplicate."""
entities = [
{"company_id": "AAPL", "evidence_id": "ev1"},
{"company_id": "AAPL", "evidence_id": "ev1"},
]
evidence_spans = {"ev1": "Apple news."}
groups = build_evidence_groups(entities, evidence_spans)
assert groups["AAPL"].evidence_ids == ["ev1"]
assert len(groups["AAPL"].texts) == 1
def test_relations_add_evidence_to_company(self):
"""Relations parameter links additional evidence to companies."""
entities = [{"company_id": "AAPL", "evidence_id": "ev1"}]
relations = [
{"company_id": "AAPL", "evidence_id": "ev2", "relation_type": "directly_affects"},
]
evidence_spans = {
"ev1": "Apple reported earnings.",
"ev2": "iPhone demand surged globally.",
}
groups = build_evidence_groups(entities, evidence_spans, relations=relations)
assert "AAPL" in groups
assert "ev1" in groups["AAPL"].evidence_ids
assert "ev2" in groups["AAPL"].evidence_ids
assert len(groups["AAPL"].evidence_ids) == 2
def test_relations_create_new_company_group(self):
"""Relations can create groups for companies not in entities."""
entities = [{"company_id": "AAPL", "evidence_id": "ev1"}]
relations = [
{"company_id": "GOOGL", "evidence_id": "ev2", "relation_type": "inferred_exposure"},
]
evidence_spans = {
"ev1": "Apple expanded.",
"ev2": "Google was affected.",
}
groups = build_evidence_groups(entities, evidence_spans, relations=relations)
assert "AAPL" in groups
assert "GOOGL" in groups
assert groups["GOOGL"].evidence_ids == ["ev2"]
def test_relations_none_skipped(self):
"""None relations parameter is handled gracefully."""
entities = [{"company_id": "AAPL", "evidence_id": "ev1"}]
evidence_spans = {"ev1": "Apple news."}
groups = build_evidence_groups(entities, evidence_spans, relations=None)
assert "AAPL" in groups
assert groups["AAPL"].evidence_ids == ["ev1"]
class TestFinBERTAdapter:
"""Test FinBERT adapter returns valid probability distributions."""
def setup_method(self):
self.adapter = FinBERTAdapter(test_mode=True)
def test_model_version_exposed(self):
assert self.adapter.model_version == "ProsusAI/finbert@v1.0"
assert self.adapter.model_name == "ProsusAI/finbert"
def test_empty_input(self):
result = self.adapter.classify([])
assert result == []
def test_positive_text(self):
result = self.adapter.classify(["Company reported strong profit growth."])
assert len(result) == 1
pos, neg, neu = result[0]
assert pos > neg
assert pos > neu
assert abs(pos + neg + neu - 1.0) < 1e-6
def test_negative_text(self):
result = self.adapter.classify(["Revenue declined sharply amid weak demand."])
assert len(result) == 1
pos, neg, neu = result[0]
assert neg > pos
assert neg > neu
def test_neutral_text(self):
result = self.adapter.classify(["The company held its annual general meeting today."])
assert len(result) == 1
pos, neg, neu = result[0]
assert neu > pos
assert neu > neg
def test_mixed_keywords_text(self):
result = self.adapter.classify(["Revenue growth was strong but the decline in margins hurt"])
assert len(result) == 1
pos, neg, neu = result[0]
assert pos >= 0.3
assert neg >= 0.3
def test_batch_classification(self):
texts = [
"Earnings beat expectations.",
"Stock plunged on weak results.",
"Board met to discuss routine matters.",
]
results = self.adapter.classify(texts)
assert len(results) == 3
assert results[0][0] > results[0][1]
assert results[1][1] > results[1][0]
assert results[2][2] > results[2][0]
assert results[2][2] > results[2][1]
def test_probabilities_sum_to_one(self):
texts = ["Strong growth.", "Major loss.", "Neutral report."]
results = self.adapter.classify(texts)
for pos, neg, neu in results:
assert abs(pos + neg + neu - 1.0) < 1e-6
assert pos >= 0.0
assert neg >= 0.0
assert neu >= 0.0
class TestAggregation:
"""Test aggregate_evidence_sentiments from the aggregation module."""
def test_single_positive_text(self):
scores = [TextSentiment(evidence_id="ev1", positive_prob=0.8, negative_prob=0.1, neutral_prob=0.1)]
result = aggregate_evidence_sentiments("AAPL", scores, "test_model")
assert result.label == "positive"
assert result.company_id == "AAPL"
assert result.is_mixed is False
assert len(result.per_text_scores) == 1
assert result.per_text_scores[0].evidence_id == "ev1"
def test_single_negative_text(self):
scores = [TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.8, neutral_prob=0.1)]
result = aggregate_evidence_sentiments("GOOGL", scores, "test_model")
assert result.label == "negative"
assert result.is_mixed is False
def test_mixed_from_disagreeing_texts(self):
"""Two texts: one positive, one negative -> mixed."""
scores = [
TextSentiment(evidence_id="ev1", positive_prob=0.75, negative_prob=0.10, neutral_prob=0.15),
TextSentiment(evidence_id="ev2", positive_prob=0.10, negative_prob=0.75, neutral_prob=0.15),
]
result = aggregate_evidence_sentiments("TSLA", scores, "test_model")
assert result.label == "mixed"
assert result.is_mixed is True
assert result.positive_prob > 0.3
assert result.negative_prob > 0.3
def test_not_mixed_when_agreement(self):
"""Two positive texts should not trigger mixed."""
scores = [
TextSentiment(evidence_id="ev1", positive_prob=0.70, negative_prob=0.15, neutral_prob=0.15),
TextSentiment(evidence_id="ev2", positive_prob=0.65, negative_prob=0.20, neutral_prob=0.15),
]
result = aggregate_evidence_sentiments("AAPL", scores, "test_model")
assert result.label == "positive"
assert result.is_mixed is False
def test_empty_scores_neutral(self):
result = aggregate_evidence_sentiments("X", [], "test_model")
assert result.label == "neutral"
assert result.neutral_prob == 1.0
assert result.is_mixed is False
def test_probabilities_sum_to_one(self):
scores = [
TextSentiment(evidence_id="ev1", positive_prob=0.60, negative_prob=0.25, neutral_prob=0.15),
TextSentiment(evidence_id="ev2", positive_prob=0.30, negative_prob=0.50, neutral_prob=0.20),
TextSentiment(evidence_id="ev3", positive_prob=0.10, negative_prob=0.10, neutral_prob=0.80),
]
result = aggregate_evidence_sentiments("X", scores, "test_model")
total = result.positive_prob + result.negative_prob + result.neutral_prob
assert abs(total - 1.0) < 1e-4
def test_evidence_ids_preserved(self):
scores = [
TextSentiment(evidence_id="ev1", positive_prob=0.5, negative_prob=0.3, neutral_prob=0.2),
TextSentiment(evidence_id="ev2", positive_prob=0.4, negative_prob=0.4, neutral_prob=0.2),
]
result = aggregate_evidence_sentiments("AAPL", scores, "model_v1")
assert result.evidence_ids == ["ev1", "ev2"]
assert result.model_version == "model_v1"
assert result.calibration_version == "uncalibrated"
def test_disagreement_threshold_boundary(self):
"""Both max pos and max neg must be >= threshold for mixed."""
threshold = MIXED_DISAGREEMENT_THRESHOLD
scores = [
TextSentiment(evidence_id="ev1", positive_prob=threshold, negative_prob=0.05, neutral_prob=1.0 - threshold - 0.05),
TextSentiment(evidence_id="ev2", positive_prob=0.05, negative_prob=threshold, neutral_prob=1.0 - threshold - 0.05),
]
result = aggregate_evidence_sentiments("X", scores, "test")
assert result.is_mixed is True
assert result.label == "mixed"
def test_below_disagreement_threshold_not_mixed(self):
"""Below threshold should not be mixed."""
threshold = MIXED_DISAGREEMENT_THRESHOLD
scores = [
TextSentiment(evidence_id="ev1", positive_prob=threshold - 0.01, negative_prob=0.05, neutral_prob=1.0 - (threshold - 0.01) - 0.05),
TextSentiment(evidence_id="ev2", positive_prob=0.05, negative_prob=threshold - 0.01, neutral_prob=1.0 - (threshold - 0.01) - 0.05),
]
result = aggregate_evidence_sentiments("X", scores, "test")
assert result.is_mixed is False
class TestMixedSentiment:
"""Test mixed sentiment detection from evidence-group disagreement (legacy API)."""
def test_single_positive_group(self):
result = compute_mixed_sentiment(
company_id="AAPL",
group_results=[(0.75, 0.10, 0.15)],
evidence_ids=["ev1"],
model_version="ProsusAI/finbert@v1.0",
)
assert result.label == "positive"
assert result.company_id == "AAPL"
assert result.positive_prob > result.negative_prob
assert result.is_mixed is False
def test_single_negative_group(self):
result = compute_mixed_sentiment(
company_id="GOOGL",
group_results=[(0.10, 0.75, 0.15)],
evidence_ids=["ev1"],
model_version="ProsusAI/finbert@v1.0",
)
assert result.label == "negative"
assert result.negative_prob > result.positive_prob
def test_single_neutral_group(self):
result = compute_mixed_sentiment(
company_id="MSFT",
group_results=[(0.15, 0.15, 0.70)],
evidence_ids=["ev1"],
model_version="ProsusAI/finbert@v1.0",
)
assert result.label == "neutral"
assert result.neutral_prob > result.positive_prob
assert result.neutral_prob > result.negative_prob
def test_mixed_from_disagreeing_groups(self):
"""Two groups: one positive, one negative -> mixed."""
group_results = [
(0.75, 0.10, 0.15),
(0.10, 0.75, 0.15),
]
result = compute_mixed_sentiment(
company_id="TSLA",
group_results=group_results,
evidence_ids=["ev1", "ev2"],
model_version="ProsusAI/finbert@v1.0",
)
assert result.label == "mixed"
assert result.is_mixed is True
assert result.positive_prob > 0.3
assert result.negative_prob > 0.3
def test_no_mixed_when_agreement(self):
"""Two positive groups should not trigger mixed."""
group_results = [
(0.70, 0.15, 0.15),
(0.65, 0.20, 0.15),
]
result = compute_mixed_sentiment(
company_id="AAPL",
group_results=group_results,
evidence_ids=["ev1", "ev2"],
model_version="ProsusAI/finbert@v1.0",
)
assert result.label == "positive"
assert result.is_mixed is False
def test_disagreement_threshold_boundary(self):
"""Both max pos and max neg must be >= threshold for mixed."""
group_results = [
(DISAGREEMENT_THRESHOLD, 0.05, 0.65),
(0.05, DISAGREEMENT_THRESHOLD, 0.65),
]
result = compute_mixed_sentiment(
company_id="X",
group_results=group_results,
evidence_ids=["ev1", "ev2"],
model_version="test",
)
assert result.label == "mixed"
assert result.is_mixed is True
def test_below_disagreement_threshold(self):
"""Below threshold should not be mixed."""
group_results = [
(DISAGREEMENT_THRESHOLD - 0.01, 0.05, 0.66),
(0.05, DISAGREEMENT_THRESHOLD - 0.01, 0.66),
]
result = compute_mixed_sentiment(
company_id="X",
group_results=group_results,
evidence_ids=["ev1", "ev2"],
model_version="test",
)
assert result.label == "neutral"
assert result.is_mixed is False
def test_empty_group_results(self):
result = compute_mixed_sentiment(
company_id="AAPL",
group_results=[],
evidence_ids=[],
model_version="test",
)
assert result.label == "neutral"
assert result.neutral_prob == 1.0
assert result.is_mixed is False
def test_probabilities_sum_to_one(self):
group_results = [
(0.60, 0.25, 0.15),
(0.30, 0.50, 0.20),
(0.10, 0.10, 0.80),
]
result = compute_mixed_sentiment(
company_id="X",
group_results=group_results,
evidence_ids=["a", "b", "c"],
model_version="test",
)
total = result.positive_prob + result.negative_prob + result.neutral_prob
assert abs(total - 1.0) < 1e-4
def test_per_text_scores_preserved(self):
"""Legacy API now populates per_text_scores for provenance."""
group_results = [(0.75, 0.10, 0.15), (0.20, 0.60, 0.20)]
result = compute_mixed_sentiment(
company_id="X",
group_results=group_results,
evidence_ids=["ev1", "ev2"],
model_version="test",
)
assert len(result.per_text_scores) == 2
assert result.per_text_scores[0].evidence_id == "ev1"
assert result.per_text_scores[1].evidence_id == "ev2"
class TestSentimentScorer:
"""Test SentimentScorer integration (end-to-end scoring)."""
@pytest.mark.asyncio
async def test_score_positive_evidence(self):
scorer = SentimentScorer()
group = EvidenceGroup(
company_id="AAPL",
evidence_ids=["ev1"],
texts=["Apple reported strong profit growth."],
)
result = await scorer.score(group)
assert result.company_id == "AAPL"
assert result.label == "positive"
assert result.positive_prob > result.negative_prob
assert len(result.per_text_scores) == 1
assert result.per_text_scores[0].evidence_id == "ev1"
assert result.model_version == "ProsusAI/finbert@v1.0"
assert result.calibration_version == "uncalibrated"
@pytest.mark.asyncio
async def test_score_negative_evidence(self):
scorer = SentimentScorer()
group = EvidenceGroup(
company_id="GOOGL",
evidence_ids=["ev1"],
texts=["Google experienced a sharp decline in revenue."],
)
result = await scorer.score(group)
assert result.label == "negative"
assert result.negative_prob > result.positive_prob
@pytest.mark.asyncio
async def test_score_mixed_evidence(self):
"""Multiple texts with opposing sentiment triggers mixed."""
scorer = SentimentScorer()
group = EvidenceGroup(
company_id="TSLA",
evidence_ids=["ev1", "ev2"],
texts=[
"Tesla revenue growth exceeded expectations.",
"Tesla faces major decline in margins and weak demand.",
],
)
result = await scorer.score(group)
assert result.label == "mixed"
assert result.is_mixed is True
assert len(result.per_text_scores) == 2
@pytest.mark.asyncio
async def test_score_batch(self):
scorer = SentimentScorer()
groups = {
"AAPL": EvidenceGroup(
company_id="AAPL",
evidence_ids=["ev1"],
texts=["Apple beat earnings estimates."],
),
"GOOGL": EvidenceGroup(
company_id="GOOGL",
evidence_ids=["ev2"],
texts=["Google saw weak ad revenue and decline in users"],
),
}
batch_result = await scorer.score_batch(groups)
assert len(batch_result.results) == 2
assert batch_result.model_version == "ProsusAI/finbert@v1.0"
assert batch_result.processing_time_ms >= 0
labels = {r.company_id: r.label for r in batch_result.results}
assert labels["AAPL"] == "positive"
assert labels["GOOGL"] == "negative"
@pytest.mark.asyncio
async def test_score_probability_distributions_sum_to_one(self):
scorer = SentimentScorer()
group = EvidenceGroup(
company_id="X",
evidence_ids=["ev1", "ev2", "ev3"],
texts=["Profit rose.", "Demand weakened.", "Board meeting held."],
)
result = await scorer.score(group)
# Overall probabilities sum to 1
total = result.positive_prob + result.negative_prob + result.neutral_prob
assert abs(total - 1.0) < 1e-4
# Per-text probabilities also sum to 1
for ts in result.per_text_scores:
text_total = ts.positive_prob + ts.negative_prob + ts.neutral_prob
assert abs(text_total - 1.0) < 1e-6
@pytest.mark.asyncio
async def test_custom_model_protocol(self):
"""SentimentScorer works with any model implementing SentimentModel."""
class MockModel:
@property
def model_version(self) -> str:
return "mock@v1"
def classify(self, texts: list[str]) -> list[tuple[float, float, float]]:
return [(0.5, 0.3, 0.2)] * len(texts)
scorer = SentimentScorer(model=MockModel())
group = EvidenceGroup(
company_id="X",
evidence_ids=["ev1"],
texts=["Any text."],
)
result = await scorer.score(group)
assert result.model_version == "mock@v1"
assert result.positive_prob > 0.4
class TestMultiCompanyOpposingSentiments:
"""Test that opposing sentiments for different companies produce separate records."""
def test_separate_records_for_opposing_companies(self):
"""Article with positive Apple news and negative Google news."""
entities = [
{"company_id": "AAPL", "evidence_id": "ev1"},
{"company_id": "GOOGL", "evidence_id": "ev2"},
]
evidence_spans = {
"ev1": "Apple reported record profit growth.",
"ev2": "Google faces a major decline in ad revenue.",
}
groups = build_evidence_groups(entities, evidence_spans)
adapter = FinBERTAdapter(test_mode=True)
results: list[CompanySentimentResult] = []
for company_id, group in groups.items():
probs = adapter.classify(group.texts)
result = compute_mixed_sentiment(
company_id=company_id,
group_results=probs,
evidence_ids=group.evidence_ids,
model_version=adapter.model_version,
)
results.append(result)
assert len(results) == 2
company_labels = {r.company_id: r.label for r in results}
assert company_labels["AAPL"] == "positive"
assert company_labels["GOOGL"] == "negative"
class TestCalibrator:
"""Test sentiment probability calibration passthrough and fitting."""
def test_uncalibrated_passthrough(self):
"""Unfitted calibrator should pass through raw probabilities."""
cal = SentimentCalibrator(method="isotonic")
assert not cal.is_fitted
assert cal.calibration_version == "uncalibrated"
raw = [0.6, 0.3, 0.1]
result = cal.calibrate(raw)
assert result == raw
def test_isotonic_fit_and_calibrate(self):
"""Fitted isotonic calibrator should transform probabilities."""
cal = SentimentCalibrator(method="isotonic")
raw_probs = [
[0.8, 0.1, 0.1],
[0.7, 0.2, 0.1],
[0.1, 0.8, 0.1],
[0.2, 0.7, 0.1],
[0.1, 0.1, 0.8],
[0.1, 0.2, 0.7],
[0.9, 0.05, 0.05],
[0.05, 0.9, 0.05],
[0.05, 0.05, 0.9],
[0.6, 0.3, 0.1],
]
true_labels = [0, 0, 1, 1, 2, 2, 0, 1, 2, 0]
cal.fit(raw_probs, true_labels, version="gold_v1")
assert cal.is_fitted
assert cal.calibration_version == "gold_v1"
result = cal.calibrate([0.7, 0.2, 0.1])
assert len(result) == 3
assert all(0.0 <= p <= 1.0 for p in result)
assert abs(sum(result) - 1.0) < 1e-6
def test_calibration_preserves_ordering(self):
"""Higher raw probabilities should map to higher calibrated values."""
cal = SentimentCalibrator(method="isotonic")
raw_probs = [
[0.9, 0.05, 0.05],
[0.8, 0.1, 0.1],
[0.7, 0.15, 0.15],
[0.6, 0.2, 0.2],
[0.3, 0.6, 0.1],
[0.2, 0.7, 0.1],
[0.1, 0.8, 0.1],
[0.1, 0.1, 0.8],
[0.15, 0.15, 0.7],
[0.2, 0.2, 0.6],
]
true_labels = [0, 0, 0, 0, 1, 1, 1, 2, 2, 2]
cal.fit(raw_probs, true_labels, version="test_v1")
low_pos = cal.calibrate([0.3, 0.5, 0.2])
high_pos = cal.calibrate([0.8, 0.1, 0.1])
assert high_pos[0] >= low_pos[0]
def test_platt_calibration(self):
"""Platt scaling should also produce valid probabilities."""
cal = SentimentCalibrator(method="platt")
raw_probs = [
[0.8, 0.1, 0.1],
[0.7, 0.2, 0.1],
[0.1, 0.8, 0.1],
[0.2, 0.7, 0.1],
[0.1, 0.1, 0.8],
[0.1, 0.2, 0.7],
[0.9, 0.05, 0.05],
[0.05, 0.9, 0.05],
[0.05, 0.05, 0.9],
[0.6, 0.3, 0.1],
]
true_labels = [0, 0, 1, 1, 2, 2, 0, 1, 2, 0]
cal.fit(raw_probs, true_labels, version="platt_v1")
assert cal.is_fitted
result = cal.calibrate([0.6, 0.3, 0.1])
assert len(result) == 3
assert all(0.0 <= p <= 1.0 for p in result)
assert abs(sum(result) - 1.0) < 1e-6
def test_batch_calibrate(self):
"""Batch calibration should produce consistent results."""
cal = SentimentCalibrator(method="isotonic")
raw_probs = [
[0.9, 0.05, 0.05],
[0.1, 0.8, 0.1],
[0.1, 0.1, 0.8],
[0.7, 0.2, 0.1],
[0.2, 0.7, 0.1],
[0.2, 0.1, 0.7],
]
true_labels = [0, 1, 2, 0, 1, 2]
cal.fit(raw_probs, true_labels, version="batch_v1")
batch = [[0.7, 0.2, 0.1], [0.2, 0.7, 0.1]]
results = cal.calibrate_batch(batch)
assert len(results) == 2
for r in results:
assert abs(sum(r) - 1.0) < 1e-6
def test_fit_validation_errors(self):
cal = SentimentCalibrator()
with pytest.raises(ValueError):
cal.fit([], [])
with pytest.raises(ValueError):
cal.fit([[0.5, 0.3, 0.2]], [0, 1]) # Length mismatch
class TestModels:
"""Test data model validation."""
def test_evidence_group_requires_non_empty_ids(self):
with pytest.raises(ValueError):
EvidenceGroup(company_id="AAPL", evidence_ids=[], texts=["test"])
def test_evidence_group_requires_non_empty_texts(self):
with pytest.raises(ValueError):
EvidenceGroup(company_id="AAPL", evidence_ids=["ev1"], texts=[])
def test_company_sentiment_result_valid_labels(self):
for label in ("positive", "negative", "neutral", "mixed"):
result = CompanySentimentResult(
company_id="X",
label=label,
positive_prob=0.33,
negative_prob=0.33,
neutral_prob=0.34,
evidence_ids=["ev1"],
model_version="test",
)
assert result.label == label
def test_company_sentiment_result_invalid_label(self):
with pytest.raises(ValueError):
CompanySentimentResult(
company_id="X",
label="very_positive",
positive_prob=0.8,
negative_prob=0.1,
neutral_prob=0.1,
evidence_ids=["ev1"],
model_version="test",
)
def test_sentiment_batch_result(self):
result = SentimentBatchResult(
results=[
CompanySentimentResult(
company_id="AAPL",
label="positive",
positive_prob=0.8,
negative_prob=0.1,
neutral_prob=0.1,
evidence_ids=["ev1"],
model_version="test",
)
],
model_version="test",
processing_time_ms=150,
)
assert len(result.results) == 1
assert result.processing_time_ms == 150
def test_text_sentiment_model(self):
ts = TextSentiment(
evidence_id="ev1",
positive_prob=0.7,
negative_prob=0.2,
neutral_prob=0.1,
)
assert ts.evidence_id == "ev1"
assert ts.dominant_label == "positive"
assert abs(ts.positive_prob + ts.negative_prob + ts.neutral_prob - 1.0) < 1e-6
def test_text_sentiment_dominant_negative(self):
ts = TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.7, neutral_prob=0.2)
assert ts.dominant_label == "negative"
def test_text_sentiment_dominant_neutral(self):
ts = TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.2, neutral_prob=0.7)
assert ts.dominant_label == "neutral"
def test_company_sentiment_result_is_mixed_field(self):
result = CompanySentimentResult(
company_id="X",
label="mixed",
positive_prob=0.4,
negative_prob=0.4,
neutral_prob=0.2,
evidence_ids=["ev1", "ev2"],
is_mixed=True,
model_version="test",
)
assert result.is_mixed is True
@@ -0,0 +1,607 @@
"""Contract and load tests for the specialist inference service.
Tests entity extraction, classification, relation extraction, structured
extraction, health/ready endpoints, batch size enforcement, dynamic batching,
bounded queue rejection, and model version.
"""
from __future__ import annotations
import asyncio
import os
import pytest
from fastapi.testclient import TestClient
# Force test mode before importing the app
os.environ["SPECIALIST_TEST_MODE"] = "1"
from services.specialist.app import app # noqa: E402
from services.specialist.batching import DynamicBatcher, QueueFullError # noqa: E402
@pytest.fixture
def client():
"""Create a test client with the specialist app."""
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# Health / Ready endpoints
# ---------------------------------------------------------------------------
class TestHealthEndpoints:
"""Test health and readiness probes."""
def test_health_returns_ok(self, client: TestClient):
resp = client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
def test_ready_returns_ready_after_startup(self, client: TestClient):
resp = client.get("/ready")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ready"
assert "model" in data
assert "uptime_seconds" in data
def test_metrics_endpoint(self, client: TestClient):
resp = client.get("/metrics")
assert resp.status_code == 200
data = resp.json()
assert data["specialist_model_loaded"] == 1
assert "specialist_uptime_seconds" in data
assert "specialist_max_batch_size" in data
assert "specialist_max_queue_size" in data
assert "specialist_total_batches" in data
assert "specialist_total_items" in data
assert "specialist_total_rejections" in data
assert "specialist_queue_depth" in data
# ---------------------------------------------------------------------------
# Entity extraction
# ---------------------------------------------------------------------------
class TestEntityExtraction:
"""Test POST /api/specialist/entities."""
def test_entity_extraction_returns_spans_with_offsets(self, client: TestClient):
payload = {
"texts": ["Apple Inc reported Q3 revenue of $81.4 billion."],
"schema_labels": ["company", "financial_metric", "date"],
}
resp = client.post("/api/specialist/entities", json=payload)
assert resp.status_code == 200
data = resp.json()
assert "results" in data
assert "model_version" in data
assert "schema_version" in data
assert "processing_time_ms" in data
assert data["processing_time_ms"] >= 0
# Results should be a list of lists (one per input text)
assert len(data["results"]) == 1
entities = data["results"][0]
# Should find at least one entity
assert len(entities) > 0
# Each entity should have required fields
for entity in entities:
assert "text" in entity
assert "entity_type" in entity
assert "start_char" in entity
assert "end_char" in entity
assert "score" in entity
assert "model_version" in entity
assert "schema_version" in entity
assert entity["start_char"] >= 0
assert entity["end_char"] > entity["start_char"]
assert 0.0 <= entity["score"] <= 1.0
def test_entity_extraction_character_offsets_match_source(self, client: TestClient):
text = "Apple Inc reported Q3 revenue of $81.4 billion."
payload = {
"texts": [text],
"schema_labels": ["company", "date"],
}
resp = client.post("/api/specialist/entities", json=payload)
data = resp.json()
entities = data["results"][0]
for entity in entities:
# The extracted text should match the source at the given offsets
extracted_from_source = text[entity["start_char"]:entity["end_char"]]
assert extracted_from_source == entity["text"]
def test_entity_extraction_batch_multiple_texts(self, client: TestClient):
payload = {
"texts": [
"Apple reported strong earnings.",
"Tesla announced new factory plans.",
"Microsoft acquired a small startup.",
],
"schema_labels": ["company"],
}
resp = client.post("/api/specialist/entities", json=payload)
assert resp.status_code == 200
data = resp.json()
# Should have one result list per input text
assert len(data["results"]) == 3
def test_entity_extraction_with_batch_id(self, client: TestClient):
payload = {
"texts": ["Apple Q3 results beat expectations."],
"schema_labels": ["company"],
"batch_id": "test-batch-001",
}
resp = client.post("/api/specialist/entities", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["batch_id"] == "test-batch-001"
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
class TestClassification:
"""Test POST /api/specialist/classify."""
def test_classification_returns_labels_with_scores(self, client: TestClient):
payload = {
"texts": ["Apple reported quarterly earnings beating analyst expectations."],
"schema_labels": ["earnings", "acquisition", "product_launch", "legal"],
}
resp = client.post("/api/specialist/classify", json=payload)
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 1
classifications = data["results"][0]
assert len(classifications) > 0
for cls in classifications:
assert "text" in cls
assert "label" in cls
assert "score" in cls
assert "model_version" in cls
assert "schema_version" in cls
assert 0.0 <= cls["score"] <= 1.0
def test_classification_batch_processing(self, client: TestClient):
payload = {
"texts": [
"Company announces merger.",
"New product launched today.",
"CEO resigned unexpectedly.",
],
"schema_labels": ["acquisition", "product_launch", "management"],
}
resp = client.post("/api/specialist/classify", json=payload)
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 3
# ---------------------------------------------------------------------------
# Relation extraction
# ---------------------------------------------------------------------------
class TestRelationExtraction:
"""Test POST /api/specialist/relations."""
def test_relation_extraction_returns_triples(self, client: TestClient):
payload = {
"texts": ["Apple acquired Google subsidiary for $2 billion."],
"schema_labels": ["acquired", "competes_with", "supplies"],
}
resp = client.post("/api/specialist/relations", json=payload)
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 1
relations = data["results"][0]
# With Apple and Google in text, the mock should find a relation
if relations:
for rel in relations:
assert "subject" in rel
assert "subject_type" in rel
assert "subject_start" in rel
assert "subject_end" in rel
assert "relation" in rel
assert "object" in rel
assert "object_type" in rel
assert "object_start" in rel
assert "object_end" in rel
assert "score" in rel
assert "model_version" in rel
assert "schema_version" in rel
assert 0.0 <= rel["score"] <= 1.0
# ---------------------------------------------------------------------------
# Structured extraction
# ---------------------------------------------------------------------------
class TestStructuredExtraction:
"""Test POST /api/specialist/extract."""
def test_structured_extraction_returns_facts(self, client: TestClient):
payload = {
"texts": ["Revenue was $81.4 billion, up 8% year over year."],
"schema_labels": ["revenue", "growth_rate"],
}
resp = client.post("/api/specialist/extract", json=payload)
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 1
structured = data["results"][0]
if structured:
for item in structured:
assert "text" in item
assert "field" in item
assert "value" in item
assert "start_char" in item
assert "end_char" in item
assert "score" in item
assert "model_version" in item
assert "schema_version" in item
# ---------------------------------------------------------------------------
# Model version in response
# ---------------------------------------------------------------------------
class TestModelVersion:
"""Test that model version and schema version are present in all responses."""
def test_entity_response_contains_model_version(self, client: TestClient):
payload = {
"texts": ["Tesla reported record deliveries."],
"schema_labels": ["company"],
}
resp = client.post("/api/specialist/entities", json=payload)
data = resp.json()
assert "model_version" in data
assert "schema_version" in data
assert data["schema_version"] == "specialist-v1"
def test_classification_response_contains_model_version(self, client: TestClient):
payload = {
"texts": ["Earnings beat expectations."],
"schema_labels": ["earnings"],
}
resp = client.post("/api/specialist/classify", json=payload)
data = resp.json()
assert "model_version" in data
assert "schema_version" in data
def test_relations_response_contains_model_version(self, client: TestClient):
payload = {
"texts": ["Apple and Google compete in AI."],
"schema_labels": ["competes_with"],
}
resp = client.post("/api/specialist/relations", json=payload)
data = resp.json()
assert "model_version" in data
assert "schema_version" in data
def test_structured_response_contains_model_version(self, client: TestClient):
payload = {
"texts": ["Revenue was $50 billion."],
"schema_labels": ["revenue"],
}
resp = client.post("/api/specialist/extract", json=payload)
data = resp.json()
assert "model_version" in data
assert "schema_version" in data
# ---------------------------------------------------------------------------
# Batch size enforcement
# ---------------------------------------------------------------------------
class TestBatchSizeEnforcement:
"""Test that exceeding max_batch_size is rejected."""
def test_exceeding_max_batch_size_returns_422(self, client: TestClient):
# Default max_batch_size is 32, send 33 texts
texts = [f"Text number {i}" for i in range(33)]
payload = {
"texts": texts,
"schema_labels": ["company"],
}
resp = client.post("/api/specialist/entities", json=payload)
assert resp.status_code == 422
data = resp.json()
assert "maximum" in data["detail"].lower() or "exceeds" in data["detail"].lower()
def test_at_max_batch_size_succeeds(self, client: TestClient):
# 32 texts should be fine
texts = [f"Apple reported earnings for period {i}." for i in range(32)]
payload = {
"texts": texts,
"schema_labels": ["company"],
}
resp = client.post("/api/specialist/entities", json=payload)
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 32
def test_empty_texts_rejected(self, client: TestClient):
payload = {
"texts": [],
"schema_labels": ["company"],
}
resp = client.post("/api/specialist/entities", json=payload)
# Pydantic min_length=1 should reject this
assert resp.status_code == 422
def test_empty_labels_rejected(self, client: TestClient):
payload = {
"texts": ["Some text"],
"schema_labels": [],
}
resp = client.post("/api/specialist/entities", json=payload)
assert resp.status_code == 422
def test_classify_enforces_batch_limit(self, client: TestClient):
texts = [f"Text {i}" for i in range(33)]
payload = {"texts": texts, "schema_labels": ["earnings"]}
resp = client.post("/api/specialist/classify", json=payload)
assert resp.status_code == 422
def test_relations_enforces_batch_limit(self, client: TestClient):
texts = [f"Text {i}" for i in range(33)]
payload = {"texts": texts, "schema_labels": ["competes_with"]}
resp = client.post("/api/specialist/relations", json=payload)
assert resp.status_code == 422
def test_extract_enforces_batch_limit(self, client: TestClient):
texts = [f"Text {i}" for i in range(33)]
payload = {"texts": texts, "schema_labels": ["revenue"]}
resp = client.post("/api/specialist/extract", json=payload)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Load test (lightweight simulation)
# ---------------------------------------------------------------------------
class TestLoadSimulation:
"""Basic load simulation — process many batches sequentially."""
def test_sequential_batch_throughput(self, client: TestClient):
"""Process 10 batches of 10 texts and ensure consistent results."""
total_ms = 0.0
for i in range(10):
payload = {
"texts": [f"Apple reported Q{j % 4 + 1} results." for j in range(10)],
"schema_labels": ["company", "date"],
}
resp = client.post("/api/specialist/entities", json=payload)
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 10
total_ms += data["processing_time_ms"]
# All 100 documents processed — just confirm no errors
assert total_ms >= 0
def test_mixed_endpoint_load(self, client: TestClient):
"""Call all endpoints in sequence to simulate mixed load."""
entity_payload = {
"texts": ["Apple Q3 revenue beat."],
"schema_labels": ["company"],
}
classify_payload = {
"texts": ["Major acquisition announced."],
"schema_labels": ["acquisition", "earnings"],
}
relation_payload = {
"texts": ["Apple and Google compete in phones."],
"schema_labels": ["competes_with"],
}
structured_payload = {
"texts": ["Revenue was $50 billion."],
"schema_labels": ["revenue"],
}
for _ in range(5):
assert client.post("/api/specialist/entities", json=entity_payload).status_code == 200
assert client.post("/api/specialist/classify", json=classify_payload).status_code == 200
assert client.post("/api/specialist/relations", json=relation_payload).status_code == 200
assert client.post("/api/specialist/extract", json=structured_payload).status_code == 200
def test_concurrent_batch_load(self, client: TestClient):
"""Simulate rapid sequential calls to stress the service."""
payload = {
"texts": [f"Company {i} announced results." for i in range(16)],
"schema_labels": ["company", "earnings", "date"],
}
# 20 rapid sequential requests
for _ in range(20):
resp = client.post("/api/specialist/entities", json=payload)
assert resp.status_code == 200
data = resp.json()
assert len(data["results"]) == 16
assert data["processing_time_ms"] >= 0
# ---------------------------------------------------------------------------
# Dynamic batching
# ---------------------------------------------------------------------------
class TestDynamicBatching:
"""Test that the DynamicBatcher correctly collects and processes requests."""
@pytest.mark.asyncio
async def test_batcher_processes_single_item(self):
"""Single item submitted should be processed as a batch of one."""
processed_batches: list[list] = []
def process_fn(payloads):
processed_batches.append(payloads)
return [p * 2 for p in payloads]
batcher = DynamicBatcher(
max_batch_size=4, max_wait_ms=50.0, max_queue_size=16
)
batcher.start(process_fn)
result = await batcher.submit(5)
assert result == 10
assert len(processed_batches) >= 1
await batcher.stop()
@pytest.mark.asyncio
async def test_batcher_collects_concurrent_items(self):
"""Multiple concurrent submissions should be batched together."""
processed_batches: list[list] = []
def process_fn(payloads):
processed_batches.append(list(payloads))
return [p + 100 for p in payloads]
batcher = DynamicBatcher(
max_batch_size=8, max_wait_ms=200.0, max_queue_size=64
)
batcher.start(process_fn)
results = await asyncio.gather(
batcher.submit(1),
batcher.submit(2),
batcher.submit(3),
batcher.submit(4),
)
assert sorted(results) == [101, 102, 103, 104]
total_items = sum(len(b) for b in processed_batches)
assert total_items == 4
await batcher.stop()
@pytest.mark.asyncio
async def test_batcher_respects_max_batch_size(self):
"""Batcher should not exceed max_batch_size per batch."""
batch_sizes: list[int] = []
def process_fn(payloads):
batch_sizes.append(len(payloads))
return list(range(len(payloads)))
batcher = DynamicBatcher(
max_batch_size=3, max_wait_ms=500.0, max_queue_size=64
)
batcher.start(process_fn)
await asyncio.gather(
batcher.submit("a"),
batcher.submit("b"),
batcher.submit("c"),
batcher.submit("d"),
batcher.submit("e"),
)
for size in batch_sizes:
assert size <= 3
await batcher.stop()
@pytest.mark.asyncio
async def test_batcher_metrics_tracked(self):
"""Batcher should track processed items and batches."""
def process_fn(payloads):
return [None] * len(payloads)
batcher = DynamicBatcher(
max_batch_size=4, max_wait_ms=50.0, max_queue_size=16
)
batcher.start(process_fn)
await asyncio.gather(
batcher.submit("x"),
batcher.submit("y"),
)
await asyncio.sleep(0.1)
assert batcher.total_items_processed >= 2
assert batcher.total_batches_processed >= 1
assert batcher.total_rejections == 0
await batcher.stop()
# ---------------------------------------------------------------------------
# Bounded queue rejection
# ---------------------------------------------------------------------------
class TestBoundedQueue:
"""Test that the bounded queue rejects overflow."""
@pytest.mark.asyncio
async def test_queue_full_raises_error(self):
"""When the queue is full, new submissions raise QueueFullError."""
batcher = DynamicBatcher(
max_batch_size=32, max_wait_ms=50.0, max_queue_size=3
)
# Intentionally NOT calling batcher.start() — no background loop
# means items remain in the queue.
batcher._running = True # Allow submit to not raise other issues
# Fill the queue to capacity
loop = asyncio.get_running_loop()
for i in range(3):
from services.specialist.batching import _PendingRequest
pending = _PendingRequest(
payload=i,
future=loop.create_future(),
)
batcher._queue.put_nowait(pending)
# Queue is full — next submit should raise QueueFullError
with pytest.raises(QueueFullError):
await batcher.submit(999)
assert batcher.total_rejections >= 1
assert batcher.queue_size == 3
@pytest.mark.asyncio
async def test_queue_size_property(self):
"""queue_size should reflect current pending items."""
def process_fn(payloads):
return [None] * len(payloads)
batcher = DynamicBatcher(
max_batch_size=32, max_wait_ms=500.0, max_queue_size=100
)
batcher.start(process_fn)
assert batcher.queue_size == 0
await batcher.submit("test")
await asyncio.sleep(0.15)
assert batcher.queue_size == 0
await batcher.stop()
@@ -0,0 +1,124 @@
"""Tests for active learning export — Task 49."""
from __future__ import annotations
from services.intelligence_pipeline_v3.active_learning.exporter import (
ActiveLearningExporter,
ContentPolicy,
ExportConfig,
SelectionCriteria,
)
class TestActiveLearningExporter:
"""Task 49.1-49.3: Selection, filtering, versioned export."""
def test_select_low_confidence(self):
exporter = ActiveLearningExporter(config=ExportConfig())
record = exporter.select_record(
document_id="doc-001",
criteria=SelectionCriteria.LOW_CONFIDENCE,
source_spans=[{"text": "Apple beat Q4 estimates", "start": 0, "end": 23}],
document_type="news",
entity_labels=[{"type": "company", "text": "Apple"}],
confidence_scores={"entity_extraction": 0.3},
)
assert record is not None
assert record.selection_criteria == SelectionCriteria.LOW_CONFIDENCE
assert record.export_version == "1.0"
def test_select_adjudicated(self):
exporter = ActiveLearningExporter(config=ExportConfig())
record = exporter.select_record(
document_id="doc-002",
criteria=SelectionCriteria.ADJUDICATED,
source_spans=[{"text": "complex filing", "start": 0, "end": 14}],
adjudicator_decisions=[{"resolved_ticker": "AAPL", "confidence": 0.9}],
)
assert record is not None
assert record.adjudicator_decisions[0]["resolved_ticker"] == "AAPL"
def test_select_corrected(self):
exporter = ActiveLearningExporter(config=ExportConfig())
record = exporter.select_record(
document_id="doc-003",
criteria=SelectionCriteria.REVIEWER_CORRECTED,
source_spans=[{"text": "quarterly revenue", "start": 0, "end": 17}],
reviewer_corrections=[
{"field": "sentiment", "from": "positive", "to": "negative"}
],
)
assert record is not None
assert len(record.reviewer_corrections) == 1
def test_content_policy_redact(self):
config = ExportConfig(content_policy=ContentPolicy.REDACT_PII)
exporter = ActiveLearningExporter(config=config)
record = exporter.select_record(
document_id="doc-004",
criteria=SelectionCriteria.LOW_CONFIDENCE,
source_spans=[{"text": "John Smith at Apple", "start": 0, "end": 19}],
)
assert record is not None
# Spans are marked with policy applied
assert record.source_spans[0].get("content_policy_applied") == "redact_pii"
def test_content_policy_exclude(self):
config = ExportConfig(
content_policy=ContentPolicy.EXCLUDE,
sensitive_patterns=["classified"],
)
exporter = ActiveLearningExporter(config=config)
record = exporter.select_record(
document_id="doc-005",
criteria=SelectionCriteria.LOW_CONFIDENCE,
source_spans=[{"text": "This is classified information", "start": 0, "end": 30}],
)
assert record is None
assert exporter.total_excluded == 1
def test_max_export_count(self):
config = ExportConfig(max_export_count=2)
exporter = ActiveLearningExporter(config=config)
for i in range(5):
exporter.select_record(
document_id=f"doc-{i}",
criteria=SelectionCriteria.LOW_CONFIDENCE,
source_spans=[{"text": f"text {i}", "start": 0, "end": 5}],
)
assert exporter.total_exported == 2
def test_export_manifest(self):
config = ExportConfig(export_version="2.0")
exporter = ActiveLearningExporter(config=config)
exporter.select_record(
document_id="doc-001",
criteria=SelectionCriteria.LOW_CONFIDENCE,
source_spans=[{"text": "test", "start": 0, "end": 4}],
)
exporter.select_record(
document_id="doc-002",
criteria=SelectionCriteria.ADJUDICATED,
source_spans=[{"text": "test2", "start": 0, "end": 5}],
)
manifest = exporter.export_manifest()
assert manifest["export_version"] == "2.0"
assert manifest["total_records"] == 2
assert manifest["selection_criteria_distribution"]["low_confidence"] == 1
assert manifest["selection_criteria_distribution"]["adjudicated"] == 1
def test_versioned_format_includes_provenance(self):
exporter = ActiveLearningExporter(config=ExportConfig())
from uuid import uuid4
run_id = uuid4()
record = exporter.select_record(
document_id="doc-001",
criteria=SelectionCriteria.CONFLICTING,
source_spans=[{"text": "test", "start": 0, "end": 4}],
pipeline_run_id=run_id,
model_versions={"gliner": "2.0", "finbert": "1.1"},
)
assert record is not None
assert record.pipeline_run_id == run_id
assert record.model_versions["gliner"] == "2.0"
@@ -0,0 +1,158 @@
"""Tests for audit/review module — Task 44."""
from __future__ import annotations
from uuid import uuid4
from services.intelligence_pipeline_v3.audit.models import (
AuditRecord,
CorrectionEvent,
CorrectionType,
ReviewFilter,
ReviewStatus,
)
from services.intelligence_pipeline_v3.audit.store import AuditStore
class TestAuditRecord:
"""Task 44.1-44.2: Evidence display and specialist output tracking."""
def test_create_record(self):
record = AuditRecord.create(
document_id="doc-001",
run_id=uuid4(),
evidence_spans=[{"text": "Apple reported Q4 revenue", "start": 0, "end": 25}],
specialist_outputs={"sentiment": {"positive": 0.8}},
routing_reasons=["HIGH_CONFIDENCE"],
route_decision="fast_path",
)
assert record.document_id == "doc-001"
assert record.review_status == ReviewStatus.PENDING
assert len(record.evidence_spans) == 1
def test_add_correction(self):
record = AuditRecord.create(
document_id="doc-001", run_id=uuid4()
)
correction = CorrectionEvent.create(
record_id=record.record_id,
field_name="sentiment",
correction_type=CorrectionType.INCORRECT,
original_value="positive",
corrected_value="negative",
reviewer_id="reviewer-1",
)
record.add_correction(correction)
assert record.review_status == ReviewStatus.CORRECTED
assert len(record.corrections) == 1
def test_corrections_are_immutable(self):
correction = CorrectionEvent.create(
record_id=uuid4(),
field_name="ticker",
correction_type=CorrectionType.CORRECT,
original_value="AAPL",
reviewer_id="reviewer-1",
)
# Frozen dataclass — cannot modify
assert correction.event_id is not None
assert correction.timestamp is not None
def test_mark_reviewed(self):
record = AuditRecord.create(document_id="doc-001", run_id=uuid4())
record.mark_reviewed()
assert record.review_status == ReviewStatus.REVIEWED
def test_mark_confirmed(self):
record = AuditRecord.create(document_id="doc-001", run_id=uuid4())
record.mark_confirmed()
assert record.review_status == ReviewStatus.CONFIRMED
class TestReviewFilter:
"""Task 44.4: Filters for low confidence, unsupported claims, adjudicated."""
def test_filter_adjudicated(self):
f = ReviewFilter(is_adjudicated=True)
record_adj = AuditRecord.create(
document_id="doc-001",
run_id=uuid4(),
adjudicator_decision={"resolved": True},
)
record_fast = AuditRecord.create(
document_id="doc-002", run_id=uuid4()
)
assert f.matches(record_adj)
assert not f.matches(record_fast)
def test_filter_by_review_status(self):
f = ReviewFilter(review_status=ReviewStatus.CORRECTED)
record = AuditRecord.create(document_id="doc-001", run_id=uuid4())
assert not f.matches(record)
record.review_status = ReviewStatus.CORRECTED
assert f.matches(record)
def test_filter_unsupported_claims(self):
f = ReviewFilter(has_unsupported_claims=True)
record = AuditRecord.create(document_id="doc-001", run_id=uuid4())
assert not f.matches(record)
# Add unsupported correction
record.add_correction(
CorrectionEvent.create(
record_id=record.record_id,
field_name="fact",
correction_type=CorrectionType.UNSUPPORTED,
original_value="revenue beat",
reviewer_id="r1",
)
)
assert f.matches(record)
class TestAuditStore:
"""Task 44: Storage and retrieval."""
def test_store_and_retrieve(self):
store = AuditStore()
record = AuditRecord.create(document_id="doc-001", run_id=uuid4())
store.store(record)
assert store.get(record.record_id) is record
assert store.count() == 1
def test_get_by_document(self):
store = AuditStore()
run1 = uuid4()
run2 = uuid4()
store.store(AuditRecord.create(document_id="doc-001", run_id=run1))
store.store(AuditRecord.create(document_id="doc-001", run_id=run2))
store.store(AuditRecord.create(document_id="doc-002", run_id=uuid4()))
assert len(store.get_by_document("doc-001")) == 2
def test_add_correction_to_record(self):
store = AuditStore()
record = AuditRecord.create(document_id="doc-001", run_id=uuid4())
store.store(record)
correction = CorrectionEvent.create(
record_id=record.record_id,
field_name="ticker",
correction_type=CorrectionType.VALUE_OVERRIDE,
original_value="GOOG",
corrected_value="GOOGL",
reviewer_id="r1",
)
assert store.add_correction(record.record_id, correction)
assert store.correction_count() == 1
def test_filter(self):
store = AuditStore()
r1 = AuditRecord.create(
document_id="doc-001",
run_id=uuid4(),
adjudicator_decision={"x": 1},
)
r2 = AuditRecord.create(document_id="doc-002", run_id=uuid4())
store.store(r1)
store.store(r2)
results = store.filter(ReviewFilter(is_adjudicated=True))
assert len(results) == 1
assert results[0].document_id == "doc-001"
@@ -0,0 +1,215 @@
"""Tests for canary module — Tasks 47-48."""
from __future__ import annotations
from services.intelligence_pipeline_v3.canary.influence import (
DivergenceRecord,
PromotionStatus,
SignalInfluenceConfig,
SignalInfluenceTracker,
)
from services.intelligence_pipeline_v3.canary.routing import (
CanaryConfig,
CanaryRouter,
RollbackReason,
)
class TestCanaryRouter:
"""Task 47: Canary compatibility outputs."""
def test_disabled_always_v2(self):
router = CanaryRouter(config=CanaryConfig(enabled=False))
assert not router.should_use_v3("doc-001")
def test_percentage_routing_deterministic(self):
config = CanaryConfig(enabled=True, percentage=50)
router = CanaryRouter(config=config)
result1 = router.should_use_v3("doc-001")
# Reset counters to test determinism
router2 = CanaryRouter(config=CanaryConfig(enabled=True, percentage=50))
result2 = router2.should_use_v3("doc-001")
assert result1 == result2
def test_trading_excluded_by_default(self):
config = CanaryConfig(enabled=True, percentage=100, exclude_trading=True)
router = CanaryRouter(config=config)
assert not router.should_use_v3("doc-001", is_trading_consumer=True)
assert router.should_use_v3("doc-001", is_trading_consumer=False)
def test_document_type_filter(self):
config = CanaryConfig(
enabled=True, percentage=100, document_types={"news", "filing"}
)
router = CanaryRouter(config=config)
assert router.should_use_v3("doc-001", document_type="news")
assert not router.should_use_v3("doc-002", document_type="transcript")
def test_rollback_on_error_rate(self):
config = CanaryConfig(enabled=True, percentage=20, max_error_rate=0.05)
router = CanaryRouter(config=config)
event = router.check_rollback(error_rate=0.10)
assert event is not None
assert event.reason == RollbackReason.ERROR_RATE
assert router.config.percentage == 0 # Rolled back
def test_rollback_on_latency(self):
config = CanaryConfig(
enabled=True, percentage=30, max_p95_latency_ms=3000
)
router = CanaryRouter(config=config)
event = router.check_rollback(p95_latency_ms=5000)
assert event is not None
assert event.reason == RollbackReason.LATENCY_THRESHOLD
def test_rollback_on_low_availability(self):
config = CanaryConfig(
enabled=True, percentage=10, min_availability=0.95
)
router = CanaryRouter(config=config)
event = router.check_rollback(availability=0.90)
assert event is not None
assert event.reason == RollbackReason.AVAILABILITY_THRESHOLD
def test_rollback_on_low_correctness(self):
config = CanaryConfig(
enabled=True, percentage=10, min_correctness=0.90
)
router = CanaryRouter(config=config)
event = router.check_rollback(correctness=0.85)
assert event is not None
assert event.reason == RollbackReason.CORRECTNESS_THRESHOLD
def test_no_rollback_when_healthy(self):
config = CanaryConfig(enabled=True, percentage=50)
router = CanaryRouter(config=config)
event = router.check_rollback(
error_rate=0.01,
p95_latency_ms=1000,
queue_saturation=0.3,
availability=0.99,
correctness=0.95,
)
assert event is None
def test_manual_rollback(self):
config = CanaryConfig(enabled=True, percentage=25)
router = CanaryRouter(config=config)
event = router.manual_rollback("operator requested")
assert event.reason == RollbackReason.MANUAL
assert event.previous_percentage == 25
assert router.config.percentage == 0
def test_rollback_preserves_audit_records(self):
"""Rollback changes routing, not stored v3 data."""
config = CanaryConfig(enabled=True, percentage=50)
router = CanaryRouter(config=config)
# Process some docs
router.should_use_v3("doc-001")
router.should_use_v3("doc-002")
# Rollback
router.manual_rollback()
# Audit records (rollback events) are preserved
assert len(router.rollback_events) == 1
def test_traffic_ratio(self):
config = CanaryConfig(enabled=True, percentage=100)
router = CanaryRouter(config=config)
for i in range(10):
router.should_use_v3(f"doc-{i}")
assert router.v3_traffic_ratio == 1.0
class TestSignalInfluence:
"""Task 48: Canary signal influence in paper trading."""
def test_start_paper_trading(self):
tracker = SignalInfluenceTracker(
config=SignalInfluenceConfig()
)
tracker.start_paper_trading()
assert tracker.promotion_status == PromotionStatus.PAPER_TRADING
def test_record_divergence(self):
tracker = SignalInfluenceTracker(
config=SignalInfluenceConfig(enabled=True)
)
tracker.record_signal(is_v3=True)
div = DivergenceRecord.create(
document_id="doc-001",
v2_recommendation={"direction": "buy"},
v3_recommendation={"direction": "sell"},
divergence_type="direction_opposite",
)
tracker.record_divergence(div)
assert tracker.divergence_rate == 1.0
def test_extraction_and_trading_separate(self):
"""Task 48.2: Separate extraction correctness from trading outcomes."""
tracker = SignalInfluenceTracker(
config=SignalInfluenceConfig(
enabled=True,
report_extraction_separately=True,
report_trading_separately=True,
)
)
tracker.update_extraction_metrics({"entity_f1": 0.92})
tracker.update_trading_metrics({"sharpe": 1.5})
summary = tracker.summary()
assert summary["extraction_metrics"]["entity_f1"] == 0.92
assert summary["trading_metrics"]["sharpe"] == 1.5
def test_approval_requires_owner(self):
config = SignalInfluenceConfig(
enabled=True,
require_owner_approval=True,
owner_id="owner-1",
)
tracker = SignalInfluenceTracker(config=config)
# Wrong approver
assert not tracker.approve("random-person")
# Right approver
assert tracker.approve("owner-1")
assert tracker.promotion_status == PromotionStatus.APPROVED
def test_approval_requires_all_divergences_reviewed(self):
config = SignalInfluenceConfig(
enabled=True,
require_owner_approval=False,
max_divergence_rate=1.0, # Allow any rate so we test review requirement
)
tracker = SignalInfluenceTracker(config=config)
tracker.record_signal(is_v3=True)
div = DivergenceRecord.create(
"doc-001", {"d": "buy"}, {"d": "sell"}, "opposite"
)
tracker.record_divergence(div)
# Cannot approve with unreviewed divergences
assert not tracker.approve("owner")
# Mark reviewed
div.reviewed = True
assert tracker.approve("owner")
def test_reject(self):
tracker = SignalInfluenceTracker(config=SignalInfluenceConfig())
tracker.reject("too many divergences")
assert tracker.promotion_status == PromotionStatus.REJECTED
def test_trading_outcomes_dont_override_correctness(self):
"""Requirement 16.10: Trading performance cannot override failed gates."""
config = SignalInfluenceConfig(
enabled=True,
require_owner_approval=False,
max_divergence_rate=0.10,
)
tracker = SignalInfluenceTracker(config=config)
# Simulate 10 v3 signals, 5 divergences (50% rate)
for i in range(10):
tracker.record_signal(is_v3=True)
for i in range(5):
tracker.record_divergence(
DivergenceRecord.create(f"doc-{i}", {}, {}, "opposite")
)
# Even if trading metrics are good, correctness gates fail
tracker.update_trading_metrics({"sharpe": 3.0})
assert not tracker.approve("owner")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
"""Tests for deprecation tracking module — Task 51."""
from __future__ import annotations
from services.intelligence_pipeline_v3.deprecation.tracker import (
DEFAULT_DEPRECATIONS,
DeprecationEntry,
DeprecationStatus,
DeprecationTracker,
MigrationReport,
)
class TestDeprecationEntry:
"""Task 51: Deprecation lifecycle."""
def test_create_entry(self):
entry = DeprecationEntry.create(
component_name="VLLMClient",
component_path="services/extractor/vllm_client.py",
reason="Replaced by OpenAICompatibleClient",
known_consumers=["worker.py", "thesis_llm.py"],
replacement="services/shared/inference/clients/openai_compatible.py",
)
assert entry.status == DeprecationStatus.DEPRECATED
assert entry.migration_progress == 0.0
assert not entry.all_consumers_migrated
def test_mark_consumer_migrated(self):
entry = DeprecationEntry.create(
component_name="VLLMClient",
component_path="services/extractor/vllm_client.py",
reason="Replaced",
known_consumers=["worker.py", "thesis_llm.py"],
)
entry.mark_consumer_migrated("worker.py")
assert entry.migration_progress == 0.5
entry.mark_consumer_migrated("thesis_llm.py")
assert entry.migration_progress == 1.0
assert entry.all_consumers_migrated
assert entry.status == DeprecationStatus.MIGRATION_COMPLETE
def test_approve_removal_requires_all_migrated(self):
entry = DeprecationEntry.create(
component_name="v2_prompt",
component_path="services/extractor/prompts.py",
reason="Replaced by staged extraction",
known_consumers=["worker.py"],
)
# Cannot approve before migration
assert not entry.approve_removal("admin")
# After migration
entry.mark_consumer_migrated("worker.py")
assert entry.approve_removal("admin")
assert entry.removal_approved
def test_mark_removed(self):
entry = DeprecationEntry.create(
component_name="truncation",
component_path="services/extractor/prompts.py",
reason="Replaced by segmenter",
known_consumers=["prompts.py"],
)
entry.mark_consumer_migrated("prompts.py")
entry.approve_removal("admin")
entry.mark_removed()
assert entry.status == DeprecationStatus.REMOVED
assert entry.removed_at is not None
def test_no_known_consumers_means_ready(self):
entry = DeprecationEntry.create(
component_name="old_defaults",
component_path="services/shared/config.py",
reason="Conflicting defaults removed",
known_consumers=[],
)
assert entry.all_consumers_migrated
assert entry.migration_progress == 1.0
class TestDeprecationTracker:
"""Task 51: Full deprecation tracking workflow."""
def test_add_and_get(self):
tracker = DeprecationTracker()
entry = DeprecationEntry.create(
component_name="VLLMClient",
component_path="vllm_client.py",
reason="replaced",
)
tracker.add(entry)
assert tracker.get("VLLMClient") is entry
def test_mark_migrated(self):
tracker = DeprecationTracker()
entry = DeprecationEntry.create(
component_name="VLLMClient",
component_path="vllm_client.py",
reason="replaced",
known_consumers=["worker.py"],
)
tracker.add(entry)
assert tracker.mark_migrated("VLLMClient", "worker.py")
assert tracker.get("VLLMClient").all_consumers_migrated
def test_can_remove(self):
tracker = DeprecationTracker()
entry = DeprecationEntry.create(
component_name="VLLMClient",
component_path="vllm_client.py",
reason="replaced",
known_consumers=["worker.py"],
)
tracker.add(entry)
assert not tracker.can_remove("VLLMClient")
tracker.mark_migrated("VLLMClient", "worker.py")
assert not tracker.can_remove("VLLMClient") # Not approved yet
tracker.approve_removal("VLLMClient", "admin")
assert tracker.can_remove("VLLMClient")
def test_pending_removals(self):
tracker = DeprecationTracker()
e1 = DeprecationEntry.create(
"comp1", "path1", "reason", known_consumers=["c1"]
)
e2 = DeprecationEntry.create(
"comp2", "path2", "reason", known_consumers=["c2"]
)
tracker.add(e1)
tracker.add(e2)
tracker.mark_migrated("comp1", "c1")
tracker.approve_removal("comp1", "admin")
assert len(tracker.pending_removals) == 1
assert tracker.pending_removals[0].component_name == "comp1"
def test_generate_report(self):
tracker = DeprecationTracker()
e1 = DeprecationEntry.create(
"VLLMClient", "path1", "replaced", known_consumers=["w1", "w2"]
)
e2 = DeprecationEntry.create(
"v2_prompt", "path2", "replaced", known_consumers=["w1"]
)
tracker.add(e1)
tracker.add(e2)
tracker.mark_migrated("VLLMClient", "w1")
tracker.mark_migrated("v2_prompt", "w1")
report = tracker.generate_report()
assert report.total_components == 2
assert report.deprecated == 1 # VLLMClient still has w2
assert report.migration_complete == 1 # v2_prompt is done
assert len(report.blocked_removals) == 1
class TestDefaultDeprecations:
"""Task 51: Default deprecation entries cover required components."""
def test_default_entries_defined(self):
assert len(DEFAULT_DEPRECATIONS) >= 5
def test_vllm_client_in_defaults(self):
names = [d["component_name"] for d in DEFAULT_DEPRECATIONS]
assert "VLLMClient" in names
def test_v2_prompt_in_defaults(self):
names = [d["component_name"] for d in DEFAULT_DEPRECATIONS]
assert "v2_extraction_prompt" in names
def test_provider_branching_in_defaults(self):
names = [d["component_name"] for d in DEFAULT_DEPRECATIONS]
assert "provider_branching" in names
def test_truncation_in_defaults(self):
names = [d["component_name"] for d in DEFAULT_DEPRECATIONS]
assert "8000_char_truncation" in names
def test_compatibility_adapter_in_defaults(self):
names = [d["component_name"] for d in DEFAULT_DEPRECATIONS]
assert "compatibility_adapter" in names
class TestMigrationReport:
"""Task 51.5: Archive final migration reports."""
def test_report_to_dict(self):
entries = [
DeprecationEntry.create("c1", "p1", "r", known_consumers=["x"]),
]
report = MigrationReport.generate(entries)
d = report.to_dict()
assert "total_components" in d
assert "blocked_removals" in d
@@ -0,0 +1,184 @@
"""Tests for fine-tuning module — Task 50."""
from __future__ import annotations
from uuid import uuid4
from services.intelligence_pipeline_v3.fine_tuning.evaluation import (
EvaluationResult,
ModelCard,
PromotionDecision,
)
from services.intelligence_pipeline_v3.fine_tuning.trainer import (
TrainingConfig,
TrainingRun,
TrainingStatus,
)
class TestTrainingRun:
"""Task 50.1: Training pipeline."""
def test_create_training_run(self):
config = TrainingConfig(
base_model="GLiNER2-large",
schema_version="1.0",
dataset_version="v1",
)
run = TrainingRun.create(config)
assert run.status == TrainingStatus.PENDING
assert run.config.base_model == "GLiNER2-large"
def test_lifecycle(self):
config = TrainingConfig()
run = TrainingRun.create(config)
run.start()
assert run.status == TrainingStatus.PREPARING_DATA
assert run.started_at is not None
run.begin_training()
assert run.status == TrainingStatus.TRAINING
run.begin_evaluation()
assert run.status == TrainingStatus.EVALUATING
run.complete(
artifact_path="/models/gliner2-ft-v1",
model_version="gliner2-ft-v1.0",
train_loss=0.15,
validation_loss=0.20,
best_epoch=7,
)
assert run.status == TrainingStatus.COMPLETED
assert run.model_version == "gliner2-ft-v1.0"
assert run.duration_seconds is not None
def test_failure(self):
run = TrainingRun.create(TrainingConfig())
run.start()
run.fail("OOM error during training")
assert run.status == TrainingStatus.FAILED
assert "OOM" in run.errors[0]
class TestEvaluation:
"""Task 50.2: Holdout evaluation and promotion gates."""
def test_evaluation_passes_correctness_gates(self):
result = EvaluationResult.create(
training_run_id=uuid4(),
model_version="gliner2-ft-v1.0",
entity_f1=0.92,
event_f1=0.85,
entity_f1_delta=0.02,
event_f1_delta=0.01,
calibration_ece=0.05,
)
assert result.passes_correctness_gates()
def test_evaluation_fails_on_entity_regression(self):
result = EvaluationResult.create(
training_run_id=uuid4(),
model_version="gliner2-ft-bad",
entity_f1=0.80,
entity_f1_delta=-0.05, # Regression
calibration_ece=0.05,
)
assert not result.passes_correctness_gates()
def test_evaluation_fails_on_high_calibration(self):
result = EvaluationResult.create(
training_run_id=uuid4(),
model_version="gliner2-ft-uncalibrated",
entity_f1=0.95,
entity_f1_delta=0.05,
calibration_ece=0.15, # Too high
)
assert not result.passes_correctness_gates()
def test_promotion_not_based_on_adjudication_rate(self):
"""Task 50.4: Promoted only when correctness gates pass,
not merely when adjudication rate falls.
"""
result = EvaluationResult.create(
training_run_id=uuid4(),
model_version="gliner2-ft-fewer-adj",
entity_f1=0.80,
entity_f1_delta=-0.05, # Regression!
event_f1_delta=-0.03, # Regression!
calibration_ece=0.10, # Too high!
adjudication_rate_before=0.40,
adjudication_rate_after=0.15, # Great improvement
adjudication_rate_delta=-0.25,
)
# Despite great adjudication improvement, correctness fails
assert result.promotion_decision() == PromotionDecision.REJECT
def test_promote_when_all_gates_pass(self):
result = EvaluationResult.create(
training_run_id=uuid4(),
model_version="gliner2-ft-good",
entity_f1=0.94,
entity_f1_delta=0.02,
event_f1=0.88,
event_f1_delta=0.01,
calibration_ece=0.04,
adjudication_rate_delta=-0.10,
)
assert result.promotion_decision() == PromotionDecision.PROMOTE
def test_needs_review_on_adjudication_increase(self):
result = EvaluationResult.create(
training_run_id=uuid4(),
model_version="gliner2-ft-weird",
entity_f1=0.94,
entity_f1_delta=0.02,
event_f1_delta=0.01,
calibration_ece=0.04,
adjudication_rate_delta=0.10, # Adjudication increased a lot
)
assert result.promotion_decision() == PromotionDecision.NEEDS_REVIEW
class TestModelCard:
"""Task 50: Model card with training metadata."""
def test_create_model_card(self):
card = ModelCard.create(
model_version="gliner2-ft-v1.0",
base_model="GLiNER2-large",
training_run_id=uuid4(),
training_range="2024-01 to 2024-06",
dataset_version="corpus-v1",
)
assert card.model_version == "gliner2-ft-v1.0"
assert card.base_model == "GLiNER2-large"
assert not card.promoted
assert not card.deprecated
def test_promote_and_deprecate(self):
card = ModelCard.create(
model_version="gliner2-ft-v1.0",
base_model="GLiNER2-large",
training_run_id=uuid4(),
)
card.promote()
assert card.promoted
assert card.promoted_at is not None
card.deprecate()
assert card.deprecated
def test_model_card_has_required_fields(self):
"""Requirement 17.6: Model cards must include specific fields."""
card = ModelCard.create(
model_version="v1",
base_model="GLiNER2",
training_run_id=uuid4(),
training_range="2024-01 to 2024-06",
dataset_version="v1",
schema_version="1.0",
entity_types=["company", "event"],
)
d = card.to_dict()
assert "training_range" in d
assert "dataset_version" in d
assert "intended_use" in d
assert "limitations" in d
assert "entity_types" in d
@@ -0,0 +1,162 @@
"""Tests for observability module — Task 43: traces, metrics, alerts."""
from __future__ import annotations
from uuid import uuid4
from services.intelligence_pipeline_v3.observability.metrics import (
AlertSeverity,
MetricAlert,
MetricsCollector,
StageMetrics,
)
from services.intelligence_pipeline_v3.observability.tracing import (
PipelineTrace,
SpanStatus,
TraceCollector,
)
class TestPipelineTracing:
"""Task 43.1: Trace every stage under one document trace ID."""
def test_trace_creation(self):
trace = PipelineTrace.create("doc-001", uuid4())
assert trace.document_id == "doc-001"
assert trace.trace_id is not None
assert not trace.is_complete
def test_start_and_finish_span(self):
trace = PipelineTrace.create("doc-001", uuid4())
span = trace.start_span("extraction")
assert span.stage_name == "extraction"
assert span.status == SpanStatus.RUNNING
span.finish(SpanStatus.SUCCEEDED)
assert span.status == SpanStatus.SUCCEEDED
assert span.duration_ms >= 0
def test_multiple_spans(self):
trace = PipelineTrace.create("doc-001", uuid4())
trace.start_span("segmentation").finish()
trace.start_span("extraction").finish()
trace.start_span("routing").finish()
assert len(trace.spans) == 3
def test_failed_spans_tracked(self):
trace = PipelineTrace.create("doc-001", uuid4())
trace.start_span("extraction").finish(SpanStatus.FAILED, "timeout")
trace.start_span("routing").finish(SpanStatus.SUCCEEDED)
assert len(trace.failed_spans) == 1
def test_trace_finish(self):
trace = PipelineTrace.create("doc-001", uuid4())
trace.finish()
assert trace.is_complete
assert trace.total_duration_ms >= 0
def test_to_dict_serialization(self):
trace = PipelineTrace.create("doc-001", uuid4())
trace.start_span("extraction").finish()
trace.finish()
d = trace.to_dict()
assert d["document_id"] == "doc-001"
assert d["span_count"] == 1
assert "spans" in d
class TestTraceCollector:
"""Task 43.1: Trace collection and retrieval."""
def test_start_and_get_trace(self):
collector = TraceCollector()
trace = collector.start_trace("doc-001", uuid4())
retrieved = collector.get_trace(trace.trace_id)
assert retrieved is trace
def test_get_by_document(self):
collector = TraceCollector()
run1 = uuid4()
run2 = uuid4()
collector.start_trace("doc-001", run1)
collector.start_trace("doc-001", run2)
collector.start_trace("doc-002", uuid4())
results = collector.get_by_document("doc-001")
assert len(results) == 2
def test_eviction_at_max(self):
collector = TraceCollector(max_stored=3)
for i in range(5):
collector.start_trace(f"doc-{i}", uuid4())
assert collector.trace_count == 3
class TestStageMetrics:
"""Task 43.2: Stage latency, errors, batch size, queue depth, routing."""
def test_record_invocation(self):
metrics = StageMetrics(stage_name="extraction")
metrics.record_invocation(latency_ms=150.0, tokens_in=500, tokens_out=200)
assert metrics.total_invocations == 1
assert metrics.avg_latency_ms == 150.0
assert metrics.error_rate == 0.0
def test_error_rate(self):
metrics = StageMetrics(stage_name="adjudication")
metrics.record_invocation(latency_ms=100, error=True)
metrics.record_invocation(latency_ms=100, error=False)
assert metrics.error_rate == 0.5
def test_gpu_metrics(self):
metrics = StageMetrics(stage_name="adjudication")
metrics.record_invocation(
latency_ms=500, gpu_seconds=0.5, gpu_memory_mb=4096
)
assert metrics.gpu_seconds_per_doc == 0.5
assert metrics.gpu_memory_peak_mb == 4096
def test_batch_size_tracking(self):
metrics = StageMetrics(stage_name="specialist")
metrics.record_invocation(latency_ms=50, batch_size=8)
metrics.record_invocation(latency_ms=50, batch_size=4)
assert metrics.avg_batch_size == 6.0
class TestMetricsCollector:
"""Task 43.2-43.5: Metrics collection and alerts."""
def test_record_stage(self):
collector = MetricsCollector()
collector.record_stage("extraction", latency_ms=100)
stage = collector.get_stage("extraction")
assert stage.total_invocations == 1
def test_increment_counter(self):
collector = MetricsCollector()
collector.increment_counter("schema_failures", 3)
assert collector.get_counter("schema_failures") == 3
def test_alert_evaluation(self):
alert = MetricAlert(
name="test_alert",
metric_name="error_rate",
condition="> 0.05",
severity=AlertSeverity.CRITICAL,
description="Error rate high",
threshold=0.05,
)
assert alert.evaluate(0.10) # Should fire
assert not alert.evaluate(0.03) # Should not fire
def test_check_alerts(self):
collector = MetricsCollector()
collector.increment_counter("schema_failures", 0.10)
fired = collector.check_alerts()
# schema_failure_rate_high should fire (0.10 > 0.05)
assert any(a.name == "schema_failure_rate_high" for a, _ in fired)
def test_summary(self):
collector = MetricsCollector()
collector.record_stage("extraction", latency_ms=100)
summary = collector.summary()
assert "stages" in summary
assert "extraction" in summary["stages"]
@@ -0,0 +1,804 @@
"""Contract tests for OpenAICompatibleClient.
Tests against a mocked compatible server validating all structured-output
modes, authentication, retries, metadata capture, schema validation, and
credential safety.
Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.7, 2.9
"""
from __future__ import annotations
import json
from uuid import uuid4
import httpx
import pytest
from services.shared.inference.clients.openai_compatible import (
OpenAICompatibleClient,
_redact_headers,
_resolve_auth_secret,
)
from services.shared.inference.models import (
ChatMessage,
ErrorCategory,
InferenceTarget,
ProviderCapabilities,
StructuredGenerationRequest,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_target(
*,
json_schema: bool = True,
json_object: bool = False,
seed: bool = True,
usage: bool = True,
auth_secret_ref: str | None = None,
auth_scheme: str = "bearer",
extra_headers: dict | None = None,
extra_body: dict | None = None,
max_retries: int = 2,
base_url: str = "http://test-vllm:8000",
) -> InferenceTarget:
"""Build a test InferenceTarget."""
return InferenceTarget(
endpoint_id=uuid4(),
deployment_id=uuid4(),
protocol="openai_chat",
base_url=base_url,
model="test-model",
capabilities=ProviderCapabilities(
chat_completions=True,
json_schema=json_schema,
json_object=json_object,
seed=seed,
usage=usage,
),
auth_secret_ref=auth_secret_ref,
auth_scheme=auth_scheme,
extra_headers=extra_headers or {},
extra_body=extra_body or {},
max_retries=max_retries,
)
def _make_request(
*,
schema: dict | None = None,
temperature: float = 0.0,
max_tokens: int = 1024,
) -> StructuredGenerationRequest:
"""Build a test StructuredGenerationRequest."""
return StructuredGenerationRequest(
messages=[
ChatMessage(role="system", content="You are a helpful assistant."),
ChatMessage(role="user", content="Extract the data."),
],
json_schema=schema,
max_output_tokens=max_tokens,
temperature=temperature,
seed=42,
timeout_seconds=30.0,
trace_id="test-trace-001",
)
_TEST_SCHEMA = {
"title": "test_response",
"type": "object",
"properties": {
"answer": {"type": "string"},
"confidence": {"type": "number"},
},
"required": ["answer", "confidence"],
}
def _valid_response_json() -> str:
return json.dumps({"answer": "AAPL beat earnings", "confidence": 0.95})
def _openai_response(
content: str,
status: int = 200,
*,
usage: dict | None = None,
finish_reason: str = "stop",
request_id: str | None = "req-abc-123",
) -> httpx.Response:
"""Build a fake OpenAI-compatible /v1/chat/completions response."""
body = {
"id": "chatcmpl-test",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": finish_reason,
}
],
}
if usage:
body["usage"] = usage
headers = {}
if request_id:
headers["x-request-id"] = request_id
return httpx.Response(status, json=body, headers=headers)
# ===========================================================================
# 11.1: Test /v1/chat/completions using httpx.AsyncClient
# ===========================================================================
@pytest.mark.asyncio
async def test_successful_completion_json_schema():
"""Client sends correct payload and parses json_schema response."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["payload"] = json.loads(request.content)
captured["headers"] = dict(request.headers)
return _openai_response(
_valid_response_json(),
usage={"prompt_tokens": 50, "completion_tokens": 20},
)
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target()
client = OpenAICompatibleClient(target, http_client=http)
request = _make_request(schema=_TEST_SCHEMA)
result = await client.generate(request)
# Verify URL
assert captured["url"] == "http://test-vllm:8000/v1/chat/completions"
# Verify payload structure
payload = captured["payload"]
assert payload["model"] == "test-model"
assert payload["temperature"] == 0.0
assert payload["max_tokens"] == 1024
assert payload["seed"] == 42
assert len(payload["messages"]) == 2
assert payload["messages"][0]["role"] == "system"
assert payload["messages"][1]["role"] == "user"
# Verify response_format for json_schema mode
rf = payload["response_format"]
assert rf["type"] == "json_schema"
assert rf["json_schema"]["name"] == "test_response"
assert rf["json_schema"]["strict"] is True
assert rf["json_schema"]["schema"] == _TEST_SCHEMA
# Verify result
assert result.error is None
assert result.structured_mode == "json_schema"
assert result.parsed == {"answer": "AAPL beat earnings", "confidence": 0.95}
assert result.usage.input_tokens == 50
assert result.usage.output_tokens == 20
assert result.request_id == "req-abc-123"
assert result.finish_reason == "stop"
assert result.schema_valid is True
assert result.latency_ms >= 0
await client.close()
# ===========================================================================
# 11.2: Test Bearer and configurable authentication headers
# ===========================================================================
@pytest.mark.asyncio
async def test_bearer_auth_header(monkeypatch):
"""Client sends Bearer token from env var."""
monkeypatch.setenv("VLLM_API_KEY", "secret-token-123")
captured_headers: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_headers.update(dict(request.headers))
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(auth_secret_ref="VLLM_API_KEY", auth_scheme="bearer")
client = OpenAICompatibleClient(target, http_client=http)
await client.generate(_make_request(schema=_TEST_SCHEMA))
assert captured_headers["authorization"] == "Bearer secret-token-123"
await client.close()
@pytest.mark.asyncio
async def test_custom_auth_header(monkeypatch):
"""Client sends custom auth header scheme."""
monkeypatch.setenv("CUSTOM_KEY", "my-api-key-value")
captured_headers: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_headers.update(dict(request.headers))
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(
auth_secret_ref="CUSTOM_KEY", auth_scheme="X-API-Key"
)
client = OpenAICompatibleClient(target, http_client=http)
await client.generate(_make_request(schema=_TEST_SCHEMA))
assert captured_headers["x-api-key"] == "my-api-key-value"
await client.close()
@pytest.mark.asyncio
async def test_no_auth_when_secret_ref_is_none():
"""No Authorization header when auth_secret_ref is None."""
captured_headers: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_headers.update(dict(request.headers))
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(auth_secret_ref=None)
client = OpenAICompatibleClient(target, http_client=http)
await client.generate(_make_request(schema=_TEST_SCHEMA))
assert "authorization" not in captured_headers
await client.close()
# ===========================================================================
# 11.3: Test standard response_format.json_schema payloads
# ===========================================================================
@pytest.mark.asyncio
async def test_json_schema_payload_structure():
"""json_schema mode sends correct response_format structure."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["payload"] = json.loads(request.content)
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(json_schema=True)
client = OpenAICompatibleClient(target, http_client=http)
schema = {
"title": "extraction",
"type": "object",
"properties": {"ticker": {"type": "string"}},
"required": ["ticker"],
}
await client.generate(_make_request(schema=schema))
rf = captured["payload"]["response_format"]
assert rf["type"] == "json_schema"
assert rf["json_schema"]["name"] == "extraction"
assert rf["json_schema"]["strict"] is True
assert rf["json_schema"]["schema"] == schema
await client.close()
# ===========================================================================
# 11.4: Test configurable vLLM structured_outputs extra-body payloads
# ===========================================================================
@pytest.mark.asyncio
async def test_vllm_extra_body_inclusion():
"""Extra body fields (vLLM structured_outputs) are included in payload."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["payload"] = json.loads(request.content)
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
extra_body = {
"guided_json": {"type": "object", "properties": {"x": {"type": "integer"}}},
"guided_decoding_backend": "outlines",
}
target = _make_target(extra_body=extra_body)
client = OpenAICompatibleClient(target, http_client=http)
await client.generate(_make_request(schema=_TEST_SCHEMA))
payload = captured["payload"]
assert payload["guided_json"] == extra_body["guided_json"]
assert payload["guided_decoding_backend"] == "outlines"
await client.close()
# ===========================================================================
# 11.5: Test JSON-object and prompt-only fallback policies
# ===========================================================================
@pytest.mark.asyncio
async def test_json_object_fallback():
"""Uses json_object mode when target lacks json_schema but has json_object."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["payload"] = json.loads(request.content)
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(json_schema=False, json_object=True)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert captured["payload"]["response_format"] == {"type": "json_object"}
assert result.structured_mode == "json_object"
await client.close()
@pytest.mark.asyncio
async def test_prompt_only_fallback():
"""Uses prompt_only mode when target lacks both json_schema and json_object."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["payload"] = json.loads(request.content)
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(json_schema=False, json_object=False)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
# No response_format in payload
assert "response_format" not in captured["payload"]
assert result.structured_mode == "prompt_only"
await client.close()
@pytest.mark.asyncio
async def test_no_schema_no_response_format():
"""No response_format sent when request has no json_schema."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["payload"] = json.loads(request.content)
return _openai_response("Just a plain response")
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(json_schema=True)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=None))
assert "response_format" not in captured["payload"]
assert result.structured_mode == "none"
assert result.parsed is None
await client.close()
# ===========================================================================
# 11.6: Test metadata capture (request_id, usage, finish_reason, retries)
# ===========================================================================
@pytest.mark.asyncio
async def test_metadata_capture():
"""Result captures request_id, usage, finish_reason from response."""
def handler(request: httpx.Request) -> httpx.Response:
return _openai_response(
_valid_response_json(),
usage={"prompt_tokens": 120, "completion_tokens": 45},
finish_reason="length",
request_id="req-xyz-789",
)
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target()
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.request_id == "req-xyz-789"
assert result.usage.input_tokens == 120
assert result.usage.output_tokens == 45
assert result.finish_reason == "length"
assert result.retries == 0
assert result.error is None
assert result.error_category is None
await client.close()
# ===========================================================================
# 11.6 continued: Test retry on 429 rate limit
# ===========================================================================
@pytest.mark.asyncio
async def test_retry_on_429_rate_limit():
"""Client retries on 429 and succeeds on subsequent attempt."""
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
return httpx.Response(429, text="Rate limited")
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(max_retries=2)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.error is None
assert result.retries == 1
assert call_count == 2
await client.close()
@pytest.mark.asyncio
async def test_retry_exhausted_on_500():
"""Client returns error after exhausting retries on 500."""
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(500, text="Internal Server Error")
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(max_retries=2)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.error is not None
assert result.error_category == ErrorCategory.SERVER_ERROR
assert result.retries == 2
assert call_count == 3 # initial + 2 retries
await client.close()
@pytest.mark.asyncio
async def test_retry_on_timeout():
"""Client retries on timeout and succeeds on next attempt."""
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
raise httpx.ReadTimeout("timed out")
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(max_retries=2)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.error is None
assert result.retries == 1
await client.close()
@pytest.mark.asyncio
async def test_timeout_exhausted():
"""Client returns timeout error after exhausting retries."""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("timed out")
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(max_retries=1)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.error_category == ErrorCategory.TIMEOUT
assert result.retries == 1
await client.close()
# ===========================================================================
# 11.7: Test schema validation catches invalid JSON
# ===========================================================================
@pytest.mark.asyncio
async def test_schema_validation_passes_valid_json():
"""Schema validation marks valid responses as schema_valid=True."""
def handler(request: httpx.Request) -> httpx.Response:
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target()
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.schema_valid is True
assert result.parsed is not None
await client.close()
@pytest.mark.asyncio
async def test_schema_validation_catches_invalid_json():
"""Schema validation marks responses violating the schema as invalid."""
# Missing required "confidence" field
invalid_json = json.dumps({"answer": "test"})
def handler(request: httpx.Request) -> httpx.Response:
return _openai_response(invalid_json)
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target()
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.schema_valid is False
assert result.parsed == {"answer": "test"} # Still parsed, but marked invalid
await client.close()
@pytest.mark.asyncio
async def test_unparseable_json_content():
"""Non-JSON content in structured mode results in schema_valid=False."""
def handler(request: httpx.Request) -> httpx.Response:
return _openai_response("This is not JSON at all")
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target()
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.schema_valid is False
assert result.parsed is None
await client.close()
# ===========================================================================
# 11.8: Additional contract tests
# ===========================================================================
@pytest.mark.asyncio
async def test_authentication_failure_401():
"""Client returns auth error on 401 without retrying."""
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(401, text="Unauthorized")
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(max_retries=3)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.error_category == ErrorCategory.AUTHENTICATION
assert call_count == 1 # No retries on auth failure
await client.close()
@pytest.mark.asyncio
async def test_empty_choices_error():
"""Client returns error when response has empty choices."""
def handler(request: httpx.Request) -> httpx.Response:
body = {"choices": []}
return httpx.Response(200, json=body)
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target()
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.error is not None
assert "Empty choices" in result.error
await client.close()
def test_redact_headers():
"""Sensitive headers are redacted."""
headers = {
"Authorization": "Bearer secret123",
"X-API-Key": "key456",
"Content-Type": "application/json",
}
redacted = _redact_headers(headers)
assert redacted["Authorization"] == "***REDACTED***"
assert redacted["X-API-Key"] == "***REDACTED***"
assert redacted["Content-Type"] == "application/json"
def test_resolve_auth_secret_from_env(monkeypatch):
"""Auth secret resolves from environment variable."""
monkeypatch.setenv("MY_SECRET", "resolved-value")
assert _resolve_auth_secret("MY_SECRET") == "resolved-value"
def test_resolve_auth_secret_returns_none_for_missing():
"""Auth secret returns None when env var is not set."""
assert _resolve_auth_secret("NONEXISTENT_VAR_12345") is None
def test_resolve_auth_secret_returns_none_for_none_ref():
"""Auth secret returns None when ref is None."""
assert _resolve_auth_secret(None) is None
@pytest.mark.asyncio
async def test_credentials_not_in_repr():
"""Client repr does not expose auth secrets."""
target = _make_target(auth_secret_ref="SECRET_KEY")
transport = httpx.MockTransport(
lambda req: _openai_response(_valid_response_json())
)
http = httpx.AsyncClient(transport=transport)
client = OpenAICompatibleClient(target, http_client=http)
repr_str = repr(client)
assert "SECRET_KEY" not in repr_str
assert "secret" not in repr_str.lower() or "auth_secret" not in repr_str
await client.close()
@pytest.mark.asyncio
async def test_connection_error_handling():
"""Client returns connection error after exhausting retries."""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("Connection refused")
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(max_retries=1)
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.error_category == ErrorCategory.CONNECTION_ERROR
assert result.retries == 1
await client.close()
@pytest.mark.asyncio
async def test_extra_headers_included():
"""Extra headers from target config are included in request."""
captured_headers: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured_headers.update(dict(request.headers))
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(extra_headers={"X-Custom-Header": "custom-value"})
client = OpenAICompatibleClient(target, http_client=http)
await client.generate(_make_request(schema=_TEST_SCHEMA))
assert captured_headers["x-custom-header"] == "custom-value"
await client.close()
@pytest.mark.asyncio
async def test_seed_included_when_supported():
"""Seed is included in payload when target supports it."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["payload"] = json.loads(request.content)
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(seed=True)
client = OpenAICompatibleClient(target, http_client=http)
await client.generate(_make_request(schema=_TEST_SCHEMA))
assert captured["payload"]["seed"] == 42
await client.close()
@pytest.mark.asyncio
async def test_seed_excluded_when_not_supported():
"""Seed is excluded from payload when target doesn't support it."""
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["payload"] = json.loads(request.content)
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target(seed=False)
client = OpenAICompatibleClient(target, http_client=http)
await client.generate(_make_request(schema=_TEST_SCHEMA))
assert "seed" not in captured["payload"]
await client.close()
@pytest.mark.asyncio
async def test_target_stored_in_result():
"""InferenceResult includes the target used for the request."""
def handler(request: httpx.Request) -> httpx.Response:
return _openai_response(_valid_response_json())
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target()
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.endpoint_id == target.endpoint_id
assert result.model == "test-model"
await client.close()
@pytest.mark.asyncio
async def test_no_request_id_header():
"""Result has None request_id when server doesn't send x-request-id."""
def handler(request: httpx.Request) -> httpx.Response:
return _openai_response(
_valid_response_json(), request_id=None
)
transport = httpx.MockTransport(handler)
http = httpx.AsyncClient(transport=transport)
target = _make_target()
client = OpenAICompatibleClient(target, http_client=http)
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
assert result.request_id is None
await client.close()
@@ -0,0 +1,189 @@
"""Tests for offline replay module — Task 45."""
from __future__ import annotations
from uuid import uuid4
from services.intelligence_pipeline_v3.replay.reports import (
DEFAULT_PROMOTION_GATES,
FieldReport,
GateStatus,
PromotionGate,
ReplayReport,
)
from services.intelligence_pipeline_v3.replay.runner import (
ReplayConfig,
ReplayMode,
ReplayResult,
ReplayRunner,
)
class TestReplayRunner:
"""Task 45.1: Run configurations on Gold Corpus."""
def test_create_config(self):
config = ReplayConfig.create(
mode=ReplayMode.V3_FULL,
corpus_version="1.0",
pipeline_version="v3",
)
assert config.mode == ReplayMode.V3_FULL
assert config.temperature == 0.0
assert config.strict_schema is True
def test_record_results(self):
config = ReplayConfig.create(mode=ReplayMode.V3_FULL)
runner = ReplayRunner(config=config)
runner.start()
runner.record_result(
ReplayResult(
document_id="doc-001",
config_id=config.config_id,
success=True,
latency_ms=150.0,
gpu_seconds=0.5,
)
)
runner.record_result(
ReplayResult(
document_id="doc-002",
config_id=config.config_id,
success=True,
latency_ms=200.0,
gpu_seconds=0.3,
)
)
runner.complete()
assert runner.total_documents == 2
assert runner.success_rate == 1.0
assert runner.avg_latency_ms == 175.0
assert runner.total_gpu_seconds == 0.8
def test_failure_rate(self):
config = ReplayConfig.create(mode=ReplayMode.CURRENT_V2)
runner = ReplayRunner(config=config)
runner.record_result(
ReplayResult(
document_id="doc-001",
config_id=config.config_id,
success=True,
latency_ms=100,
)
)
runner.record_result(
ReplayResult(
document_id="doc-002",
config_id=config.config_id,
success=False,
latency_ms=50,
errors=["schema_invalid"],
)
)
assert runner.success_rate == 0.5
assert runner.failure_count == 1
def test_schema_validity_rate(self):
config = ReplayConfig.create(mode=ReplayMode.V3_FAST_PATH)
runner = ReplayRunner(config=config)
for i in range(10):
runner.record_result(
ReplayResult(
document_id=f"doc-{i}",
config_id=config.config_id,
success=True,
latency_ms=100,
schema_valid=(i < 9), # 1 invalid
)
)
assert runner.schema_validity_rate == 0.9
class TestPromotionGates:
"""Task 45.3-45.4: Gate evaluation and safety-critical enforcement."""
def test_gate_passes_above_threshold(self):
gate = PromotionGate(
name="entity_f1",
metric_name="entity_f1",
threshold=0.85,
direction="above",
)
assert gate.evaluate(0.90) == GateStatus.PASSED
assert gate.evaluate(0.80) == GateStatus.FAILED
def test_gate_passes_below_threshold(self):
gate = PromotionGate(
name="calibration",
metric_name="ece",
threshold=0.08,
direction="below",
)
assert gate.evaluate(0.05) == GateStatus.PASSED
assert gate.evaluate(0.10) == GateStatus.FAILED
def test_replay_report_evaluate_all_gates(self):
report = ReplayReport(
report_id=uuid4(),
config_id=uuid4(),
baseline_config_id=uuid4(),
)
metrics = {
"entity_f1": 0.92,
"evidence_support_rate": 0.90,
"schema_validity_rate": 0.995,
"calibration_ece": 0.05,
"fast_path_rate": 0.70,
"gpu_seconds_ratio": 0.40,
}
results = report.evaluate_gates(metrics)
assert results["entity_f1"] == GateStatus.PASSED
assert results["evidence_support_rate"] == GateStatus.PASSED
assert results["schema_validity"] == GateStatus.PASSED
assert report.all_safety_gates_passed
def test_safety_critical_gate_failure(self):
report = ReplayReport(
report_id=uuid4(),
config_id=uuid4(),
baseline_config_id=uuid4(),
)
metrics = {
"entity_f1": 0.0, # Regression — fails gate
"evidence_support_rate": 0.90,
"schema_validity_rate": 0.995,
"calibration_ece": 0.05,
"fast_path_rate": 0.70,
"gpu_seconds_ratio": 0.40,
}
report.evaluate_gates(metrics)
# entity_f1 gate threshold is 0.0 (no regression), but the gate
# checks value >= threshold. 0.0 >= 0.0 passes.
# Let's check a real failure case
metrics["evidence_support_rate"] = 0.50 # Below 85% threshold
report.evaluate_gates(metrics)
assert not report.all_safety_gates_passed
def test_default_gates_exist(self):
assert len(DEFAULT_PROMOTION_GATES) >= 5
safety_gates = [g for g in DEFAULT_PROMOTION_GATES if g.safety_critical]
assert len(safety_gates) >= 2
class TestFieldReport:
"""Task 45.2: Field-level reports."""
def test_field_report_accuracy(self):
report = FieldReport(
field_name="entity",
precision=0.90,
recall=0.85,
f1=0.87,
support_count=100,
error_count=10,
)
assert report.accuracy == 0.9
def test_zero_support(self):
report = FieldReport(field_name="relation", support_count=0)
assert report.accuracy == 0.0
@@ -0,0 +1,160 @@
"""Tests for production shadow mode — Task 46."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from services.intelligence_pipeline_v3.shadow.runner import (
DisagreementLevel,
ShadowComparison,
ShadowConfig,
ShadowRunner,
)
class TestShadowRunner:
"""Task 46.1-46.4: Shadow mode operation and stability."""
def test_start_shadow(self):
runner = ShadowRunner(config=ShadowConfig())
assert not runner.is_active
runner.start()
assert runner.is_active
def test_record_comparison(self):
runner = ShadowRunner(config=ShadowConfig(enabled=True))
runner.started_at = datetime.now(timezone.utc)
comp = ShadowComparison.create(
document_id="doc-001",
v2_output={"sentiment": "positive"},
v3_output={"sentiment": "positive"},
disagreement_level=DisagreementLevel.NONE,
)
runner.record_comparison(comp)
assert runner.documents_processed == 1
def test_critical_disagreements_tracked(self):
runner = ShadowRunner(config=ShadowConfig(enabled=True))
runner.started_at = datetime.now(timezone.utc)
for i in range(3):
runner.record_comparison(
ShadowComparison.create(
document_id=f"doc-{i}",
v2_output={},
v3_output={},
disagreement_level=DisagreementLevel.CRITICAL,
)
)
assert runner.critical_disagreements == 3
def test_major_disagreement_rate(self):
runner = ShadowRunner(config=ShadowConfig(enabled=True))
runner.started_at = datetime.now(timezone.utc)
# 2 major out of 10 = 20%
for i in range(8):
runner.record_comparison(
ShadowComparison.create(
f"doc-{i}", {}, {},
disagreement_level=DisagreementLevel.MINOR,
)
)
for i in range(2):
runner.record_comparison(
ShadowComparison.create(
f"doc-major-{i}", {}, {},
disagreement_level=DisagreementLevel.MAJOR,
)
)
assert runner.major_disagreement_rate == 0.2
def test_promotion_requires_min_duration(self):
config = ShadowConfig(
enabled=True,
min_duration=timedelta(days=7),
min_documents=10,
)
runner = ShadowRunner(config=config)
runner.started_at = datetime.now(timezone.utc) # Just started
for i in range(20):
runner.record_comparison(
ShadowComparison.create(f"doc-{i}", {}, {})
)
# Not enough time elapsed
assert not runner.meets_promotion_criteria()
def test_promotion_requires_min_documents(self):
config = ShadowConfig(
enabled=True,
min_duration=timedelta(seconds=0),
min_documents=100,
)
runner = ShadowRunner(config=config)
runner.started_at = datetime.now(timezone.utc) - timedelta(days=10)
for i in range(50): # Below minimum
runner.record_comparison(
ShadowComparison.create(f"doc-{i}", {}, {})
)
assert not runner.meets_promotion_criteria()
def test_promotion_criteria_met(self):
config = ShadowConfig(
enabled=True,
min_duration=timedelta(seconds=0),
min_documents=5,
max_critical_disagreements=10,
max_major_disagreement_rate=0.5,
)
runner = ShadowRunner(config=config)
runner.started_at = datetime.now(timezone.utc) - timedelta(days=10)
for i in range(10):
runner.record_comparison(
ShadowComparison.create(f"doc-{i}", {}, {})
)
assert runner.meets_promotion_criteria()
def test_fast_path_rate_tracking(self):
runner = ShadowRunner(config=ShadowConfig(enabled=True))
runner.started_at = datetime.now(timezone.utc)
runner.record_processing(fast_path=True)
runner.record_processing(fast_path=True)
runner.record_processing(fast_path=False)
assert runner.fast_path_rate == pytest.approx(2 / 3)
def test_auto_disable_on_errors(self):
config = ShadowConfig(
enabled=True, auto_disable_on_errors=True, error_threshold=3
)
runner = ShadowRunner(config=config)
runner.started_at = datetime.now(timezone.utc)
for _ in range(3):
runner.record_error()
assert not runner.is_active
def test_get_review_sample(self):
runner = ShadowRunner(
config=ShadowConfig(enabled=True, sample_review_rate=0.5)
)
runner.started_at = datetime.now(timezone.utc)
for i in range(4):
runner.record_comparison(
ShadowComparison.create(
f"doc-{i}", {}, {},
disagreement_level=DisagreementLevel.MODERATE,
risk_score=0.5 + i * 0.1,
)
)
sample = runner.get_review_sample()
assert len(sample) == 2 # 50% of 4
# Should be sorted by priority/risk
assert sample[0].risk_score >= sample[1].risk_score
def test_summary(self):
runner = ShadowRunner(config=ShadowConfig(enabled=True))
runner.started_at = datetime.now(timezone.utc)
summary = runner.summary()
assert summary["active"] is True
assert "documents_processed" in summary
# Need this import for pytest.approx
import pytest # noqa: E402
@@ -0,0 +1,776 @@
"""Tests for evidence verification, entailment, coverage metrics, rejected store, and metrics.
Covers:
- Valid offset verification
- Invalid offset detection (text mismatch, out of bounds)
- Entity-evidence association
- Numeric consistency (value found / not found in evidence)
- Rejected candidate storage with reason codes
- RejectedCandidateStore (store, get_by_pipeline_run, get_by_reason)
- Entailment baseline (keyword overlap and exact match)
- Coverage metrics computation
- VerificationMetrics aggregation (unsupported-claim and evidence-coverage rates)
- Full verification report
"""
from __future__ import annotations
import pytest
from services.intelligence_pipeline_v3.verification.coverage import (
FieldEvidence,
compute_coverage,
)
from services.intelligence_pipeline_v3.verification.entailment import (
EntailmentVerifier,
)
from services.intelligence_pipeline_v3.verification.metrics import (
VerificationMetrics,
compute_verification_metrics,
)
from services.intelligence_pipeline_v3.verification.models import (
RejectedCandidate,
RejectionReason,
VerificationReport,
)
from services.intelligence_pipeline_v3.verification.rejected_store import (
RejectedCandidateStore,
)
from services.intelligence_pipeline_v3.verification.verifier import (
Candidate,
Entity,
EvidenceSpan,
EvidenceVerifier,
NumericFact,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
SOURCE_TEXT = (
"Apple Inc. reported revenue of $94.8 billion for Q1 2024, "
"beating analyst expectations of $92.0 billion. "
"CEO Tim Cook said the company saw strong growth in services."
)
@pytest.fixture
def source_text() -> str:
return SOURCE_TEXT
@pytest.fixture
def valid_spans(source_text: str) -> list[EvidenceSpan]:
"""Spans that exactly match the source text at declared offsets."""
return [
EvidenceSpan(
id="span-1",
start_char=0,
end_char=10,
text=source_text[0:10], # "Apple Inc."
),
EvidenceSpan(
id="span-2",
start_char=11,
end_char=58,
text=source_text[11:58],
),
EvidenceSpan(
id="span-3",
start_char=60,
end_char=107,
text=source_text[60:107],
),
]
@pytest.fixture
def verifier() -> EvidenceVerifier:
return EvidenceVerifier()
# ---------------------------------------------------------------------------
# Test: Valid offset verification
# ---------------------------------------------------------------------------
class TestOffsetVerification:
def test_valid_offsets_pass(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan], source_text: str
):
results = verifier.verify_offsets(valid_spans, source_text)
assert len(results) == 3
assert all(r.valid for r in results)
assert all(r.reason is None for r in results)
def test_text_mismatch_detected(self, verifier: EvidenceVerifier, source_text: str):
"""Span with text that doesn't match source at the declared offset."""
bad_span = EvidenceSpan(
id="span-bad",
start_char=0,
end_char=10,
text="Google LLC", # Wrong — source has "Apple Inc."
)
results = verifier.verify_offsets([bad_span], source_text)
assert len(results) == 1
assert not results[0].valid
assert "Text mismatch" in results[0].reason
def test_offset_out_of_bounds(self, verifier: EvidenceVerifier, source_text: str):
"""Span with end_char beyond source text length."""
bad_span = EvidenceSpan(
id="span-oob",
start_char=0,
end_char=len(source_text) + 100,
text="doesn't matter",
)
results = verifier.verify_offsets([bad_span], source_text)
assert len(results) == 1
assert not results[0].valid
assert "out of bounds" in results[0].reason.lower()
def test_invalid_range_end_before_start(self, verifier: EvidenceVerifier, source_text: str):
"""Span where end_char <= start_char."""
bad_span = EvidenceSpan(
id="span-reversed",
start_char=10,
end_char=5,
text="x",
)
results = verifier.verify_offsets([bad_span], source_text)
assert len(results) == 1
assert not results[0].valid
assert "Invalid range" in results[0].reason
# ---------------------------------------------------------------------------
# Test: Entity-evidence association
# ---------------------------------------------------------------------------
class TestEntityAssociation:
def test_entity_found_in_evidence(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
entity = Entity(
id="ent-1", literal_text="Apple Inc.", evidence_ids=["span-1"]
)
result = verifier.verify_entity_association(entity, valid_spans)
assert result.valid
assert result.reason is None
def test_entity_case_insensitive(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
"""Entity matching should be case-insensitive."""
entity = Entity(
id="ent-2", literal_text="apple inc.", evidence_ids=["span-1"]
)
result = verifier.verify_entity_association(entity, valid_spans)
assert result.valid
def test_entity_not_in_evidence(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
"""Entity text is not present in any linked span."""
entity = Entity(
id="ent-3", literal_text="Microsoft", evidence_ids=["span-1", "span-2"]
)
result = verifier.verify_entity_association(entity, valid_spans)
assert not result.valid
assert "not found" in result.reason.lower()
def test_entity_with_nonexistent_span_id(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
"""Entity references a span ID that doesn't exist."""
entity = Entity(
id="ent-4", literal_text="Apple", evidence_ids=["span-nonexistent"]
)
result = verifier.verify_entity_association(entity, valid_spans)
assert not result.valid
# ---------------------------------------------------------------------------
# Test: Numeric consistency
# ---------------------------------------------------------------------------
class TestNumericConsistency:
def test_literal_value_found(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
"""Literal value string is found directly in evidence text."""
fact = NumericFact(
id="fact-1",
literal_value="$94.8 billion",
normalized_value=94.8,
evidence_ids=["span-2"],
)
result = verifier.verify_numeric_consistency(fact, valid_spans)
assert result.valid
assert result.found_value == "$94.8 billion"
def test_normalized_value_match(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
"""Normalized value matches a number in evidence (without exact literal)."""
fact = NumericFact(
id="fact-2",
literal_value="92 billion", # Not exact match
normalized_value=92.0,
evidence_ids=["span-3"],
)
result = verifier.verify_numeric_consistency(fact, valid_spans)
assert result.valid
assert result.found_value == "92.0"
def test_value_not_found(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
"""Value doesn't appear in any linked evidence."""
fact = NumericFact(
id="fact-3",
literal_value="$200 billion",
normalized_value=200.0,
evidence_ids=["span-2", "span-3"],
)
result = verifier.verify_numeric_consistency(fact, valid_spans)
assert not result.valid
assert result.found_value is None
assert "not found" in result.reason.lower()
def test_tolerance_matching(self, valid_spans: list[EvidenceSpan]):
"""Values within tolerance should match."""
verifier = EvidenceVerifier(numeric_tolerance=0.02) # 2% tolerance
fact = NumericFact(
id="fact-4",
literal_value="93.8",
normalized_value=93.8, # Within 2% of 94.8
evidence_ids=["span-2"],
)
result = verifier.verify_numeric_consistency(fact, valid_spans)
assert result.valid
# ---------------------------------------------------------------------------
# Test: Rejected candidate storage (in verifier)
# ---------------------------------------------------------------------------
class TestRejectedCandidates:
def test_offset_rejection_stored(self, verifier: EvidenceVerifier, source_text: str):
bad_span = EvidenceSpan(
id="span-bad",
start_char=0,
end_char=10,
text="WRONG TEXT",
)
verifier.verify_offsets([bad_span], source_text)
rejected = verifier.rejected_candidates
assert len(rejected) == 1
assert rejected[0].rejection_reason == RejectionReason.TEXT_MISMATCH
assert rejected[0].candidate_type == "evidence_span"
assert rejected[0].stage == "offset_verification"
def test_entity_rejection_stored(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
entity = Entity(
id="ent-bad", literal_text="Nonexistent Corp", evidence_ids=["span-1"]
)
verifier.verify_entity_association(entity, valid_spans)
rejected = verifier.rejected_candidates
assert len(rejected) == 1
assert rejected[0].rejection_reason == RejectionReason.ENTITY_NOT_IN_EVIDENCE
assert rejected[0].candidate_type == "entity"
assert rejected[0].candidate_data["entity_id"] == "ent-bad"
def test_numeric_rejection_stored(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
fact = NumericFact(
id="fact-bad",
literal_value="$999",
normalized_value=999.0,
evidence_ids=["span-2"],
)
verifier.verify_numeric_consistency(fact, valid_spans)
rejected = verifier.rejected_candidates
assert len(rejected) == 1
assert rejected[0].rejection_reason == RejectionReason.NUMERIC_INCONSISTENCY
assert rejected[0].candidate_type == "fact"
def test_reset_clears_rejected(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
entity = Entity(
id="ent-x", literal_text="Nothing", evidence_ids=["span-1"]
)
verifier.verify_entity_association(entity, valid_spans)
assert len(verifier.rejected_candidates) == 1
verifier.reset()
assert len(verifier.rejected_candidates) == 0
def test_rejection_has_timestamp(
self, verifier: EvidenceVerifier, valid_spans: list[EvidenceSpan]
):
entity = Entity(
id="ent-ts", literal_text="Nobody", evidence_ids=["span-1"]
)
verifier.verify_entity_association(entity, valid_spans)
rejected = verifier.rejected_candidates
assert rejected[0].timestamp is not None
# ---------------------------------------------------------------------------
# Test: RejectedCandidateStore
# ---------------------------------------------------------------------------
class TestRejectedCandidateStore:
def test_store_and_retrieve_by_run(self):
store = RejectedCandidateStore()
rc = RejectedCandidate(
candidate_type="entity",
candidate_data={"entity_id": "e1", "text": "Apple"},
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
stage="entity_verification",
)
store.store(rc, run_id="run-001")
results = store.get_by_pipeline_run("run-001")
assert len(results) == 1
assert results[0].candidate_data["entity_id"] == "e1"
def test_retrieve_empty_run(self):
store = RejectedCandidateStore()
results = store.get_by_pipeline_run("nonexistent-run")
assert results == []
def test_store_and_retrieve_by_reason(self):
store = RejectedCandidateStore()
rc1 = RejectedCandidate(
candidate_type="entity",
candidate_data={"id": "e1"},
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
stage="entity_verification",
)
rc2 = RejectedCandidate(
candidate_type="fact",
candidate_data={"id": "f1"},
rejection_reason=RejectionReason.NUMERIC_INCONSISTENCY,
stage="numeric_verification",
)
rc3 = RejectedCandidate(
candidate_type="entity",
candidate_data={"id": "e2"},
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
stage="entity_verification",
)
store.store(rc1, run_id="run-1")
store.store(rc2, run_id="run-1")
store.store(rc3, run_id="run-2")
by_entity = store.get_by_reason(RejectionReason.ENTITY_NOT_IN_EVIDENCE)
assert len(by_entity) == 2
by_numeric = store.get_by_reason(RejectionReason.NUMERIC_INCONSISTENCY)
assert len(by_numeric) == 1
def test_store_batch(self):
store = RejectedCandidateStore()
batch = [
RejectedCandidate(
candidate_type="entity",
candidate_data={"id": f"e{i}"},
rejection_reason=RejectionReason.INVALID_OFFSET,
stage="offset_verification",
)
for i in range(5)
]
store.store_batch(batch, run_id="run-batch")
assert store.count() == 5
assert len(store.get_by_pipeline_run("run-batch")) == 5
def test_count_by_reason(self):
store = RejectedCandidateStore()
store.store(RejectedCandidate(
candidate_type="span",
candidate_data={},
rejection_reason=RejectionReason.INVALID_OFFSET,
stage="offset",
))
store.store(RejectedCandidate(
candidate_type="span",
candidate_data={},
rejection_reason=RejectionReason.INVALID_OFFSET,
stage="offset",
))
store.store(RejectedCandidate(
candidate_type="fact",
candidate_data={},
rejection_reason=RejectionReason.NUMERIC_INCONSISTENCY,
stage="numeric",
))
counts = store.count_by_reason()
assert counts["invalid_offset"] == 2
assert counts["numeric_inconsistency"] == 1
def test_clear(self):
store = RejectedCandidateStore()
store.store(RejectedCandidate(
candidate_type="entity",
candidate_data={},
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
stage="test",
), run_id="run-1")
assert store.count() == 1
store.clear()
assert store.count() == 0
assert store.get_by_pipeline_run("run-1") == []
assert store.get_by_reason(RejectionReason.ENTITY_NOT_IN_EVIDENCE) == []
# ---------------------------------------------------------------------------
# Test: Entailment baseline (keyword overlap)
# ---------------------------------------------------------------------------
class TestEntailment:
def test_exact_match_entailment(self):
ev = EntailmentVerifier()
result = ev.verify_claim(
claim="reported revenue of $94.8 billion",
evidence="Apple Inc. reported revenue of $94.8 billion for Q1 2024",
)
assert result.entailed
assert result.confidence == 1.0
assert result.method == "exact_match"
def test_keyword_overlap_entailed(self):
ev = EntailmentVerifier(keyword_threshold=0.5)
result = ev.verify_claim(
claim="Apple revenue grew significantly",
evidence="Apple Inc. reported record revenue growth of 15% year-over-year",
)
assert result.entailed
assert result.method == "keyword_overlap"
assert result.confidence >= 0.5
def test_keyword_overlap_not_entailed(self):
ev = EntailmentVerifier(keyword_threshold=0.6)
result = ev.verify_claim(
claim="Microsoft acquired a gaming company",
evidence="Apple Inc. reported revenue of $94.8 billion for Q1 2024",
)
assert not result.entailed
assert result.method == "keyword_overlap"
assert result.confidence < 0.6
def test_empty_claim(self):
ev = EntailmentVerifier()
result = ev.verify_claim(claim="", evidence="Some evidence text")
assert not result.entailed
assert result.confidence == 0.0
def test_empty_evidence(self):
ev = EntailmentVerifier()
result = ev.verify_claim(claim="Some claim", evidence="")
assert not result.entailed
assert result.confidence == 0.0
def test_batch_verification(self):
ev = EntailmentVerifier()
claims = [
"reported revenue",
"completely unrelated topic about cats",
]
evidence = "Apple reported revenue of $94.8 billion"
results = ev.verify_claims_batch(claims, evidence)
assert len(results) == 2
assert results[0].entailed # "reported revenue" is in evidence
assert not results[1].entailed # cats not related
def test_model_version_present(self):
"""EntailmentResult includes model_version field."""
ev = EntailmentVerifier()
result = ev.verify_claim(
claim="revenue growth",
evidence="The company reported strong revenue growth this quarter.",
)
assert result.model_version == "keyword_overlap_v1"
# ---------------------------------------------------------------------------
# Test: Coverage metrics
# ---------------------------------------------------------------------------
class TestCoverageMetrics:
def test_full_coverage(self):
fields = [
FieldEvidence(field_id="f1", field_name="revenue", evidence_ids=["s1", "s2"]),
FieldEvidence(field_id="f2", field_name="eps", evidence_ids=["s2"]),
]
verified = {"s1", "s2", "s3"}
metrics = compute_coverage(fields, verified)
assert metrics.total_fields == 2
assert metrics.supported_fields == 2
assert metrics.coverage_rate == 1.0
assert metrics.unsupported_claims == []
assert metrics.unsupported_rate == 0.0
def test_partial_coverage(self):
fields = [
FieldEvidence(field_id="f1", field_name="revenue", evidence_ids=["s1"]),
FieldEvidence(field_id="f2", field_name="eps", evidence_ids=["s4"]),
FieldEvidence(field_id="f3", field_name="guidance", evidence_ids=["s2"]),
]
verified = {"s1", "s2", "s3"}
metrics = compute_coverage(fields, verified)
assert metrics.total_fields == 3
assert metrics.supported_fields == 2
assert metrics.coverage_rate == pytest.approx(2 / 3)
assert metrics.unsupported_claims == ["f2"]
assert metrics.unsupported_rate == pytest.approx(1 / 3)
def test_no_coverage(self):
fields = [
FieldEvidence(field_id="f1", field_name="revenue", evidence_ids=["s99"]),
FieldEvidence(field_id="f2", field_name="eps", evidence_ids=["s100"]),
]
verified = {"s1", "s2"}
metrics = compute_coverage(fields, verified)
assert metrics.total_fields == 2
assert metrics.supported_fields == 0
assert metrics.coverage_rate == 0.0
assert len(metrics.unsupported_claims) == 2
assert metrics.unsupported_rate == 1.0
def test_empty_fields(self):
"""No fields to verify means perfect coverage by definition."""
metrics = compute_coverage([], {"s1", "s2"})
assert metrics.total_fields == 0
assert metrics.coverage_rate == 1.0
assert metrics.unsupported_rate == 0.0
def test_field_with_no_evidence_ids(self):
"""Field with empty evidence_ids is unsupported."""
fields = [
FieldEvidence(field_id="f1", field_name="revenue", evidence_ids=[]),
]
verified = {"s1", "s2"}
metrics = compute_coverage(fields, verified)
assert metrics.supported_fields == 0
assert metrics.unsupported_claims == ["f1"]
# ---------------------------------------------------------------------------
# Test: VerificationMetrics (unsupported-claim and evidence-coverage rates)
# ---------------------------------------------------------------------------
class TestVerificationMetrics:
def test_single_report_all_pass(self):
reports = [
VerificationReport(
total_candidates=10,
verified=10,
rejected=0,
coverage_rate=1.0,
rejection_breakdown={},
)
]
metrics = compute_verification_metrics(reports)
assert metrics.total_checked == 10
assert metrics.passed_count == 10
assert metrics.failed_count == 0
assert metrics.evidence_coverage_rate == 1.0
assert metrics.unsupported_claim_rate == 0.0
assert metrics.per_reason_counts == {}
def test_single_report_some_failures(self):
reports = [
VerificationReport(
total_candidates=10,
verified=7,
rejected=3,
coverage_rate=0.7,
rejection_breakdown={
"entity_not_in_evidence": 2,
"unsupported_claim": 1,
},
)
]
metrics = compute_verification_metrics(reports)
assert metrics.total_checked == 10
assert metrics.passed_count == 7
assert metrics.failed_count == 3
assert metrics.evidence_coverage_rate == 0.7
assert metrics.unsupported_claim_rate == pytest.approx(0.1)
assert metrics.per_reason_counts["entity_not_in_evidence"] == 2
assert metrics.per_reason_counts["unsupported_claim"] == 1
def test_multiple_reports_aggregated(self):
reports = [
VerificationReport(
total_candidates=5,
verified=4,
rejected=1,
coverage_rate=0.8,
rejection_breakdown={"invalid_offset": 1},
),
VerificationReport(
total_candidates=10,
verified=8,
rejected=2,
coverage_rate=0.8,
rejection_breakdown={
"numeric_inconsistency": 1,
"unsupported_claim": 1,
},
),
]
metrics = compute_verification_metrics(reports)
assert metrics.total_checked == 15
assert metrics.passed_count == 12
assert metrics.failed_count == 3
assert metrics.evidence_coverage_rate == pytest.approx(12 / 15)
assert metrics.unsupported_claim_rate == pytest.approx(1 / 15)
assert metrics.per_reason_counts["invalid_offset"] == 1
assert metrics.per_reason_counts["numeric_inconsistency"] == 1
assert metrics.per_reason_counts["unsupported_claim"] == 1
def test_empty_reports(self):
metrics = compute_verification_metrics([])
assert metrics.total_checked == 0
assert metrics.passed_count == 0
assert metrics.failed_count == 0
assert metrics.evidence_coverage_rate == 1.0
assert metrics.unsupported_claim_rate == 0.0
def test_metrics_is_frozen_dataclass(self):
"""VerificationMetrics should be immutable."""
metrics = compute_verification_metrics([])
assert isinstance(metrics, VerificationMetrics)
# ---------------------------------------------------------------------------
# Test: Full verification report
# ---------------------------------------------------------------------------
class TestFullVerificationReport:
def test_all_candidates_verified(self, source_text: str):
verifier = EvidenceVerifier()
spans = [
EvidenceSpan(
id="s1",
start_char=0,
end_char=10,
text=source_text[0:10],
),
]
candidates = [
Candidate(
candidate_type="entity",
candidate_id="c1",
candidate_data={"name": "Apple Inc."},
evidence_ids=["s1"],
literal_text="Apple Inc.",
),
]
report = verifier.verify_all(candidates, spans, source_text)
assert report.total_candidates == 1
assert report.verified == 1
assert report.rejected == 0
assert report.coverage_rate == 1.0
def test_mixed_verification(self, source_text: str):
verifier = EvidenceVerifier()
spans = [
EvidenceSpan(
id="s1",
start_char=0,
end_char=10,
text=source_text[0:10],
),
EvidenceSpan(
id="s2",
start_char=11,
end_char=58,
text=source_text[11:58],
),
]
candidates = [
Candidate(
candidate_type="entity",
candidate_id="c1",
candidate_data={"name": "Apple"},
evidence_ids=["s1"],
literal_text="Apple Inc.",
),
Candidate(
candidate_type="entity",
candidate_id="c2",
candidate_data={"name": "Microsoft"},
evidence_ids=["s1", "s2"],
literal_text="Microsoft",
),
]
report = verifier.verify_all(candidates, spans, source_text)
assert report.total_candidates == 2
assert report.verified == 1
assert report.rejected == 1
assert report.coverage_rate == 0.5
assert RejectionReason.ENTITY_NOT_IN_EVIDENCE.value in report.rejection_breakdown
def test_invalid_span_cascades_to_candidate(self, source_text: str):
"""If a candidate's only span is invalid, the candidate is rejected."""
verifier = EvidenceVerifier()
bad_span = EvidenceSpan(
id="s-bad",
start_char=0,
end_char=10,
text="WRONG TEXT", # Doesn't match source
)
candidates = [
Candidate(
candidate_type="entity",
candidate_id="c1",
candidate_data={"name": "test"},
evidence_ids=["s-bad"],
literal_text="Apple",
),
]
report = verifier.verify_all(candidates, [bad_span], source_text)
assert report.rejected == 1
assert report.verified == 0
def test_numeric_candidate_in_full_report(self, source_text: str):
verifier = EvidenceVerifier()
spans = [
EvidenceSpan(
id="s1",
start_char=11,
end_char=58,
text=source_text[11:58],
),
]
candidates = [
Candidate(
candidate_type="fact",
candidate_id="c1",
candidate_data={"type": "revenue"},
evidence_ids=["s1"],
literal_text="$94.8 billion",
normalized_value=94.8,
),
]
report = verifier.verify_all(candidates, spans, source_text)
assert report.verified == 1
assert report.rejected == 0