Files
Celes Renata a72f336ad1 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.
2026-07-13 02:14:59 +00:00

838 lines
32 KiB
Python

"""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"